@unblocklabs/unblock-memory 0.2.7 → 0.3.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.
package/README.md CHANGED
@@ -38,6 +38,10 @@ directories, or globs into named corpora:
38
38
  slots: { memory: "unblock-memory" },
39
39
  entries: {
40
40
  "unblock-memory": {
41
+ hooks: {
42
+ // Required only when skillWhisperer.enabled is true.
43
+ allowConversationAccess: true,
44
+ },
41
45
  config: {
42
46
  // Default: avoid repeated model cold starts after idle periods.
43
47
  keepEmbeddingModelWarm: true,
@@ -57,7 +61,24 @@ directories, or globs into named corpora:
57
61
  kind: "files",
58
62
  paths: ["knowledge/**/*.md"],
59
63
  },
64
+ {
65
+ name: "skills",
66
+ kind: "skills",
67
+ paths: [
68
+ "skills/**/SKILL.md",
69
+ ".agents/skills/**/SKILL.md",
70
+ "~/.agents/skills/**/SKILL.md",
71
+ "~/.openclaw/skills/**/SKILL.md",
72
+ "~/.openclaw/plugin-skills/**/SKILL.md",
73
+ ],
74
+ },
60
75
  ],
76
+ skillWhisperer: {
77
+ enabled: false,
78
+ historyMessages: 5,
79
+ minScore: 0.4,
80
+ cooldownTurns: 10,
81
+ },
61
82
  // Optional: omit unless the local analysis worker is installed.
62
83
  analysis: {
63
84
  executable: "/absolute/path/to/unblock-cluster/bin/unblock-memory-analysis",
@@ -79,11 +100,34 @@ corpus; other unique names may be added for custom material.
79
100
  context resident after first use. Set it to `false` to restore QMD's five-minute
80
101
  idle unload behavior.
81
102
 
82
- `memory_search` searches every configured corpus by default. Pass
103
+ `memory_search` searches every configured non-skill corpus by default. Pass
83
104
  `corpora: ["knowledge"]` to search selected corpora or `corpora: ["all"]` to
84
105
  request all of them explicitly. Search results include their corpus name and
85
106
  remain readable by passing the returned `qmd://` path to `memory_get`.
86
107
 
108
+ ### Skill Whisperer
109
+
110
+ Skill Whisperer is an optional semantic reminder for user turns. Configure one
111
+ isolated `skills` corpus, set `skillWhisperer.enabled` to `true`, and authorize
112
+ `plugins.entries.unblock-memory.hooks.allowConversationAccess`. The feature
113
+ embeds the current prompt plus the configured number of prior user/assistant
114
+ messages, searches only skill files, and prepends at most one name/path hint
115
+ when the best eligible match reaches `minScore`. It never opens or invokes a
116
+ skill automatically.
117
+
118
+ The defaults use five prior messages, a calibrated score threshold of `0.4`,
119
+ and a ten-turn cooldown. A skill is cooling down after either a suggestion or a
120
+ successful direct `read` of its indexed `SKILL.md`; the next result is eligible
121
+ only when it independently meets the same score threshold. Cooldown state is
122
+ per session and intentionally resets with the Gateway. Shell-command reads are
123
+ not tracked.
124
+
125
+ The `skills` corpus shares the existing QMD store and warm embedding model but
126
+ is private to Skill Whisperer: it is excluded from ordinary `memory_search`
127
+ (including `corpora: ["all"]`), `memory_get`, clustering, and memory-maintenance
128
+ tasks. Paths are explicit by design; the plugin does not reconstruct
129
+ OpenClaw's effective skill inventory from `openclaw.json`.
130
+
87
131
  Use `sessionFilter` to restrict session results by metadata while leaving file
88
132
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
89
133
  (inclusive ISO 8601 timestamps), `provider`, `chatType`, `accountId`, and
@@ -145,9 +189,10 @@ python3 -m venv .venv
145
189
  Set `analysis.executable` to the absolute path of
146
190
  `bin/unblock-memory-analysis` in that checkout. One worker installation can
147
191
  serve every agent on the host. The plugin invokes it directly with
148
- `--db <the agent's known index path>` and, when requested, a validated
149
- `--config-json <clustering options>` payload. Agents cannot choose a database,
150
- executable, shell command, or arbitrary arguments.
192
+ `--db <the agent's known index path>`, the plugin's non-skill collection IDs,
193
+ and, when requested, a validated `--config-json <clustering options>` payload.
194
+ Agents cannot choose a database, executable, collection, shell command, or
195
+ arbitrary arguments.
151
196
 
152
197
  Without the worker, `memory_list_clusters` reports that memory has not been
153
198
  analyzed and `memory_recluster` reports that analysis is unavailable. Ordinary
@@ -189,9 +234,11 @@ whole corpus for chores. Persisted exact-duplicate analysis can likewise create
189
234
  review proposals for non-session Markdown. `memory_list_maintenance_tasks`
190
235
  returns at most ten tasks, while `memory_update_maintenance_task` can resolve,
191
236
  defer, or mark one irrelevant and optionally attach a supported event date.
192
- These tools never edit or delete source Markdown. Duplicate cleanup remains a
193
- reviewed source change outside the maintenance tool, and generated session
194
- projections must never be edited directly.
237
+ For duplicate proposals, defer confirmed cleanup until the source change is
238
+ complete, mark intentional repetition irrelevant, and resolve only completed
239
+ work. These tools never edit or delete source Markdown. Duplicate cleanup
240
+ remains a reviewed source change outside the maintenance tool, and generated
241
+ session projections must never be edited directly.
195
242
 
196
243
  Member excerpts are capped at 2 KB each and 12 KB across a response; source
197
244
  aliases are capped at five per member and 50 across a response. These budgets
@@ -22,6 +22,7 @@ export type MemoryReclusterOptions = {
22
22
  export type AnalysisRunner = (params: {
23
23
  executable: string;
24
24
  dbPath: string;
25
+ collections: readonly string[];
25
26
  options?: MemoryReclusterOptions;
26
27
  signal?: AbortSignal;
27
28
  }) => Promise<void>;
@@ -95,10 +96,12 @@ export declare function clusterReference(runId: string, clusterId: number): stri
95
96
  export declare function runAnalysisWorker(params: {
96
97
  executable: string;
97
98
  dbPath: string;
99
+ collections: readonly string[];
98
100
  options?: MemoryReclusterOptions;
99
101
  signal?: AbortSignal;
100
102
  }): Promise<void>;
101
103
  export declare function latestAnalysisRunId(db: AnalysisDatabase): string | undefined;
104
+ export declare function latestAnalysisCollections(db: AnalysisDatabase): readonly string[] | undefined;
102
105
  export declare function readAnalysisSummary(db: AnalysisDatabase): MemoryAnalysisSummary | undefined;
103
106
  export declare function readClusters(db: AnalysisDatabase, requestedLimit?: number): MemoryClusterList;
104
107
  export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number, requestedOffset?: number, sort?: MemoryClusterSort, temporal?: TemporalReadOptions): MemoryClusterDetail;
@@ -104,7 +104,7 @@ export function clusterReference(runId, clusterId) {
104
104
  export function runAnalysisWorker(params) {
105
105
  return new Promise((resolve, reject) => {
106
106
  params.signal?.throwIfAborted();
107
- const args = ["--db", params.dbPath];
107
+ const args = ["--db", params.dbPath, "--collections-json", JSON.stringify(params.collections)];
108
108
  if (params.options && Object.keys(params.options).length > 0) {
109
109
  args.push("--config-json", JSON.stringify(params.options));
110
110
  }
@@ -170,6 +170,26 @@ function latestRun(db) {
170
170
  export function latestAnalysisRunId(db) {
171
171
  return latestRun(db)?.id;
172
172
  }
173
+ export function latestAnalysisCollections(db) {
174
+ const row = db.prepare(`
175
+ SELECT params_json
176
+ FROM memory_analysis_runs
177
+ WHERE completed_at IS NOT NULL
178
+ ORDER BY completed_at DESC, created_at DESC, id DESC
179
+ LIMIT 1
180
+ `).get();
181
+ if (!row)
182
+ return undefined;
183
+ try {
184
+ const collections = JSON.parse(row.params_json).collections;
185
+ return Array.isArray(collections) && collections.every((value) => typeof value === "string")
186
+ ? collections
187
+ : undefined;
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
173
193
  function count(db, sql, runId) {
174
194
  return db.prepare(sql).get(runId)?.count ?? 0;
175
195
  }
@@ -3,6 +3,11 @@ export type FileCorpusConfig = {
3
3
  kind: "files";
4
4
  paths: readonly string[];
5
5
  };
6
+ export type SkillCorpusConfig = {
7
+ name: "skills";
8
+ kind: "skills";
9
+ paths: readonly string[];
10
+ };
6
11
  declare const CHAT_TYPES: readonly ["channel", "group", "direct"];
7
12
  export type ChatType = typeof CHAT_TYPES[number];
8
13
  type SessionCorpusConfig = {
@@ -10,7 +15,7 @@ type SessionCorpusConfig = {
10
15
  kind: "sessions";
11
16
  chatTypes: readonly ChatType[];
12
17
  };
13
- export type CorpusConfig = FileCorpusConfig | SessionCorpusConfig;
18
+ export type CorpusConfig = FileCorpusConfig | SkillCorpusConfig | SessionCorpusConfig;
14
19
  export declare const DEFAULT_CORPORA: readonly FileCorpusConfig[];
15
20
  export type UnblockMemoryConfig = {
16
21
  corpora: readonly CorpusConfig[];
@@ -18,6 +23,12 @@ export type UnblockMemoryConfig = {
18
23
  analysis: {
19
24
  executable?: string;
20
25
  };
26
+ skillWhisperer: {
27
+ enabled: boolean;
28
+ historyMessages: number;
29
+ minScore: number;
30
+ cooldownTurns: number;
31
+ };
21
32
  };
22
33
  export declare function resolveConfig(value: unknown): UnblockMemoryConfig;
23
34
  export {};
@@ -6,6 +6,12 @@ export const DEFAULT_CORPORA = [{
6
6
  kind: "files",
7
7
  paths: DEFAULT_PATHS,
8
8
  }];
9
+ const DEFAULT_SKILL_WHISPERER = {
10
+ enabled: false,
11
+ historyMessages: 5,
12
+ minScore: 0.4,
13
+ cooldownTurns: 10,
14
+ };
9
15
  function assertOnlyKeys(value, allowed, label) {
10
16
  const unknown = Object.keys(value).find((key) => !allowed.includes(key));
11
17
  if (unknown)
@@ -33,6 +39,17 @@ function resolveCorpora(value) {
33
39
  if (names.has(name))
34
40
  throw new Error(`unblock-memory corpus names must be unique: ${name}`);
35
41
  names.add(name);
42
+ if (corpus.kind === "skills") {
43
+ assertOnlyKeys(corpus, ["name", "kind", "paths"], `corpora[${index}]`);
44
+ if (name !== "skills") {
45
+ throw new Error('unblock-memory skills corpus must be named "skills"');
46
+ }
47
+ if (!Array.isArray(corpus.paths) || corpus.paths.length === 0 ||
48
+ !corpus.paths.every((path) => typeof path === "string" && path.trim())) {
49
+ throw new Error("unblock-memory corpus skills paths must be a non-empty array of non-empty strings");
50
+ }
51
+ return { name: "skills", kind: "skills", paths: corpus.paths.map((path) => path.trim()) };
52
+ }
36
53
  if (corpus.kind === "sessions") {
37
54
  assertOnlyKeys(corpus, ["name", "kind", "chatTypes"], `corpora[${index}]`);
38
55
  if (name !== "sessions") {
@@ -49,8 +66,11 @@ function resolveCorpora(value) {
49
66
  if (name === "sessions") {
50
67
  throw new Error('unblock-memory corpus named "sessions" must have kind "sessions"');
51
68
  }
69
+ if (name === "skills") {
70
+ throw new Error('unblock-memory corpus named "skills" must have kind "skills"');
71
+ }
52
72
  if (corpus.kind !== "files") {
53
- throw new Error(`unblock-memory corpus ${name} must have kind "files" or "sessions"`);
73
+ throw new Error(`unblock-memory corpus ${name} must have kind "files", "skills", or "sessions"`);
54
74
  }
55
75
  if (!Array.isArray(corpus.paths) || corpus.paths.length === 0 ||
56
76
  !corpus.paths.every((path) => typeof path === "string" && path.trim())) {
@@ -65,30 +85,64 @@ function resolveCorpora(value) {
65
85
  }
66
86
  export function resolveConfig(value) {
67
87
  if (value === undefined || value === null) {
68
- return { corpora: DEFAULT_CORPORA, keepEmbeddingModelWarm: true, analysis: {} };
88
+ return {
89
+ corpora: DEFAULT_CORPORA,
90
+ keepEmbeddingModelWarm: true,
91
+ analysis: {},
92
+ skillWhisperer: DEFAULT_SKILL_WHISPERER,
93
+ };
69
94
  }
70
95
  if (typeof value !== "object" || Array.isArray(value)) {
71
96
  throw new Error("unblock-memory config must be an object");
72
97
  }
73
98
  const config = value;
74
- assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis"], "config");
99
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "skillWhisperer"], "config");
75
100
  const corpora = resolveCorpora(config.corpora);
76
101
  if (config.keepEmbeddingModelWarm !== undefined && typeof config.keepEmbeddingModelWarm !== "boolean") {
77
102
  throw new Error("unblock-memory keepEmbeddingModelWarm must be a boolean");
78
103
  }
79
104
  const keepEmbeddingModelWarm = config.keepEmbeddingModelWarm ?? true;
80
- if (config.analysis === undefined)
81
- return { corpora, keepEmbeddingModelWarm, analysis: {} };
82
- if (!config.analysis || typeof config.analysis !== "object" || Array.isArray(config.analysis)) {
83
- throw new Error("unblock-memory analysis must be an object");
105
+ let analysisConfig = {};
106
+ if (config.analysis !== undefined) {
107
+ if (!config.analysis || typeof config.analysis !== "object" || Array.isArray(config.analysis)) {
108
+ throw new Error("unblock-memory analysis must be an object");
109
+ }
110
+ const analysis = config.analysis;
111
+ assertOnlyKeys(analysis, ["executable"], "analysis");
112
+ const configured = analysis.executable;
113
+ if (configured !== undefined) {
114
+ if (typeof configured !== "string" || !configured.trim() || !isAbsolute(configured.trim())) {
115
+ throw new Error("unblock-memory analysis.executable must be an absolute non-empty path");
116
+ }
117
+ analysisConfig = { executable: configured.trim() };
118
+ }
119
+ }
120
+ let skillWhisperer = DEFAULT_SKILL_WHISPERER;
121
+ if (config.skillWhisperer !== undefined) {
122
+ if (!config.skillWhisperer || typeof config.skillWhisperer !== "object" || Array.isArray(config.skillWhisperer)) {
123
+ throw new Error("unblock-memory skillWhisperer must be an object");
124
+ }
125
+ const value = config.skillWhisperer;
126
+ assertOnlyKeys(value, ["enabled", "historyMessages", "minScore", "cooldownTurns"], "skillWhisperer");
127
+ const enabled = value.enabled ?? false;
128
+ const historyMessages = value.historyMessages ?? 5;
129
+ const minScore = value.minScore ?? 0.4;
130
+ const cooldownTurns = value.cooldownTurns ?? 10;
131
+ if (typeof enabled !== "boolean")
132
+ throw new Error("unblock-memory skillWhisperer.enabled must be a boolean");
133
+ if (typeof historyMessages !== "number" || !Number.isInteger(historyMessages) || historyMessages < 0) {
134
+ throw new Error("unblock-memory skillWhisperer.historyMessages must be a non-negative integer");
135
+ }
136
+ if (typeof minScore !== "number" || !Number.isFinite(minScore) || minScore < 0 || minScore > 1) {
137
+ throw new Error("unblock-memory skillWhisperer.minScore must be between 0 and 1");
138
+ }
139
+ if (typeof cooldownTurns !== "number" || !Number.isInteger(cooldownTurns) || cooldownTurns < 0) {
140
+ throw new Error("unblock-memory skillWhisperer.cooldownTurns must be a non-negative integer");
141
+ }
142
+ skillWhisperer = { enabled, historyMessages, minScore, cooldownTurns };
84
143
  }
85
- const analysis = config.analysis;
86
- assertOnlyKeys(analysis, ["executable"], "analysis");
87
- const configured = analysis.executable;
88
- if (configured === undefined)
89
- return { corpora, keepEmbeddingModelWarm, analysis: {} };
90
- if (typeof configured !== "string" || !configured.trim() || !isAbsolute(configured.trim())) {
91
- throw new Error("unblock-memory analysis.executable must be an absolute non-empty path");
144
+ if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
145
+ throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
92
146
  }
93
- return { corpora, keepEmbeddingModelWarm, analysis: { executable: configured.trim() } };
147
+ return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, skillWhisperer };
94
148
  }
@@ -16,6 +16,11 @@ export type ManagerSessionConfig = {
16
16
  outputDir: string;
17
17
  timezone: string;
18
18
  };
19
+ export type SkillSearchCandidate = {
20
+ name: string;
21
+ path: string;
22
+ score: number;
23
+ };
19
24
  export declare function enableSecureDelete(store: QMDStore): void;
20
25
  export declare function cleanupRemovedDocuments(store: QMDStore, changedDocuments?: number): number;
21
26
  export declare function pruneStaleCollections(store: QMDStore, configuredCollections: ReadonlySet<string>): Promise<number>;
@@ -65,6 +70,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
65
70
  };
66
71
  }): import("./curation.js").MaintenanceTask | undefined;
67
72
  search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
73
+ searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
68
74
  readFile(params: {
69
75
  relPath: string;
70
76
  from?: number;
@@ -1,7 +1,8 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { mkdir, stat } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
+ import { basename, dirname, resolve } from "node:path";
3
4
  import chokidar from "chokidar";
4
- import { ensureMemoryAnalysisSchema, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
5
+ import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
5
6
  import { CurationStore, chunkFingerprint, } from "./curation.js";
6
7
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
7
8
  import { parseSafeVirtualPath } from "./sources.js";
@@ -9,6 +10,15 @@ const DEFAULT_READ_LINES = 120;
9
10
  const MAX_READ_CHARS = 12_000;
10
11
  const WATCH_DEBOUNCE_MS = 250;
11
12
  const qmdModule = import("@unblocklabs/qmd");
13
+ function markStaleForAnalysisCollectionChange(db, collections, hasSkills) {
14
+ const current = collections.toSorted();
15
+ const previous = latestAnalysisCollections(db)?.toSorted();
16
+ if (previous
17
+ ? previous.join("\0") !== current.join("\0")
18
+ : hasSkills && latestAnalysisRunId(db) !== undefined) {
19
+ markMemoryAnalysisStale(db);
20
+ }
21
+ }
12
22
  function completedEmbeddingCount(result) {
13
23
  if (result.errors > 0) {
14
24
  throw new Error(`QMD failed to embed ${result.errors} chunk${result.errors === 1 ? "" : "s"}`);
@@ -204,7 +214,7 @@ export class QmdMemoryManager {
204
214
  }
205
215
  #startWatcher() {
206
216
  const paths = [...new Set([...this.#sources.values()]
207
- .filter((source) => source.kind === "files")
217
+ .filter((source) => source.kind !== "sessions")
208
218
  .map((source) => source.watchPath))];
209
219
  if (paths.length === 0 || this.#watcher)
210
220
  return;
@@ -239,8 +249,10 @@ export class QmdMemoryManager {
239
249
  if (this.#storeFactory) {
240
250
  this.#store = await this.#storeFactory();
241
251
  const store = this.#store;
242
- if (store.internal)
252
+ if (store.internal) {
243
253
  ensureMemoryAnalysisSchema(store.internal.db);
254
+ markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
255
+ }
244
256
  return this.#store;
245
257
  }
246
258
  const { createStore } = await qmdModule;
@@ -256,8 +268,26 @@ export class QmdMemoryManager {
256
268
  });
257
269
  enableSecureDelete(store);
258
270
  ensureMemoryAnalysisSchema(store.internal.db);
259
- const prunedDocuments = await pruneStaleCollections(store, new Set(this.#collectionNames()));
260
- if (prunedDocuments > 0)
271
+ markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
272
+ const configuredCollections = new Set(this.#allCollectionNames());
273
+ const staleCollections = (await store.getStatus()).collections
274
+ .map((collection) => collection.name)
275
+ .filter((collection) => !configuredCollections.has(collection));
276
+ const appearsInAnalysis = store.internal.db.prepare(`
277
+ SELECT 1
278
+ FROM memory_analysis_memberships membership
279
+ JOIN documents document ON document.hash = membership.hash
280
+ WHERE membership.run_id = (
281
+ SELECT id FROM memory_analysis_runs
282
+ WHERE completed_at IS NOT NULL
283
+ ORDER BY completed_at DESC, created_at DESC, id DESC
284
+ LIMIT 1
285
+ ) AND document.collection = ?
286
+ LIMIT 1
287
+ `);
288
+ const prunedAnalysisInput = staleCollections.some((collection) => appearsInAnalysis.get(collection));
289
+ const prunedDocuments = await pruneStaleCollections(store, configuredCollections);
290
+ if (prunedDocuments > 0 && prunedAnalysisInput)
261
291
  markMemoryAnalysisStale(store.internal.db);
262
292
  try {
263
293
  await ensureSemanticChunking(store);
@@ -272,22 +302,36 @@ export class QmdMemoryManager {
272
302
  this.#store = store;
273
303
  return store;
274
304
  }
305
+ #allCollectionNames() {
306
+ return [...this.#sources.keys()];
307
+ }
308
+ #analysisCollectionNames() {
309
+ return [...this.#sources.values()]
310
+ .filter((source) => source.kind !== "skills")
311
+ .map((source) => source.collection);
312
+ }
313
+ #skillCollectionNames() {
314
+ return [...this.#sources.values()]
315
+ .filter((source) => source.kind === "skills")
316
+ .map((source) => source.collection);
317
+ }
275
318
  #collectionNames(corpora) {
319
+ const publicSources = [...this.#sources.values()].filter((source) => source.kind !== "skills");
276
320
  if (corpora === undefined)
277
- return [...this.#sources.keys()];
321
+ return publicSources.map((source) => source.collection);
278
322
  if (corpora.length === 0)
279
323
  throw new Error("memory_search corpora must not be empty");
280
324
  const selected = new Set(corpora);
281
325
  if (selected.has("all")) {
282
326
  if (selected.size > 1)
283
327
  throw new Error('memory_search corpus "all" must be used alone');
284
- return [...this.#sources.keys()];
328
+ return publicSources.map((source) => source.collection);
285
329
  }
286
- const known = new Set([...this.#sources.values()].map((source) => source.corpus));
330
+ const known = new Set(publicSources.map((source) => source.corpus));
287
331
  const unknown = [...selected].find((corpus) => !known.has(corpus));
288
332
  if (unknown)
289
333
  throw new Error(`memory_search unknown corpus: ${unknown}`);
290
- return [...this.#sources.values()]
334
+ return publicSources
291
335
  .filter((source) => selected.has(source.corpus))
292
336
  .map((source) => source.collection);
293
337
  }
@@ -295,29 +339,39 @@ export class QmdMemoryManager {
295
339
  const run = async () => {
296
340
  const store = await this.#getStore();
297
341
  this.#dirty = true;
298
- const collections = [...this.#sources.values()]
299
- .filter((source) => source.kind === "files")
300
- .map((source) => source.collection);
301
- const update = await store.update({ collections });
302
- this.#cleanupRemovedDocuments?.(update.updated + update.removed);
303
342
  const analysisStore = store;
304
- const invalidatesAnalysis = update.indexed + update.updated + update.removed > 0 ||
305
- update.needsEmbedding > 0 ||
306
- params?.force === true;
307
- if (invalidatesAnalysis && analysisStore.internal) {
343
+ const collections = [...this.#sources.values()].filter((source) => source.kind !== "sessions");
344
+ let analysisMarkedStale = false;
345
+ const markAnalysisStale = () => {
346
+ if (analysisMarkedStale || !analysisStore.internal)
347
+ return;
308
348
  markMemoryAnalysisStale(analysisStore.internal.db);
349
+ analysisMarkedStale = true;
350
+ };
351
+ if (collections.length === 0) {
352
+ const update = await store.update({ collections: [] });
353
+ this.#cleanupRemovedDocuments?.(update.updated + update.removed);
354
+ if (update.indexed + update.updated + update.removed > 0 ||
355
+ update.needsEmbedding > 0 || params?.force === true) {
356
+ markAnalysisStale();
357
+ }
358
+ const embed = await store.embed({ force: params?.force, chunkStrategy: "semantic" });
359
+ if (completedEmbeddingCount(embed) > 0)
360
+ markAnalysisStale();
309
361
  }
310
- let chunksEmbedded = 0;
311
- for (const collection of collections.length > 0 ? collections : [undefined]) {
362
+ for (const source of collections) {
363
+ const update = await store.update({ collections: [source.collection] });
364
+ this.#cleanupRemovedDocuments?.(update.updated + update.removed);
365
+ const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
366
+ if (source.kind !== "skills" && (changed || params?.force === true))
367
+ markAnalysisStale();
312
368
  const embed = await store.embed({
313
- ...(collection ? { collection } : {}),
369
+ collection: source.collection,
314
370
  force: params?.force,
315
371
  chunkStrategy: "semantic",
316
372
  });
317
- chunksEmbedded += completedEmbeddingCount(embed);
318
- }
319
- if (!invalidatesAnalysis && chunksEmbedded > 0 && analysisStore.internal) {
320
- markMemoryAnalysisStale(analysisStore.internal.db);
373
+ if (source.kind !== "skills" && completedEmbeddingCount(embed) > 0)
374
+ markAnalysisStale();
321
375
  }
322
376
  const status = await store.getStatus();
323
377
  const indexedCollections = await store.listCollections();
@@ -381,6 +435,7 @@ export class QmdMemoryManager {
381
435
  await this.#analysisRunner({
382
436
  executable: this.#analysisExecutable,
383
437
  dbPath: this.#dbPath,
438
+ collections: this.#analysisCollectionNames(),
384
439
  options,
385
440
  signal,
386
441
  });
@@ -589,10 +644,54 @@ export class QmdMemoryManager {
589
644
  }];
590
645
  });
591
646
  }
647
+ async searchSkills(query, minScore, limit) {
648
+ const collections = this.#skillCollectionNames();
649
+ if (collections.length === 0)
650
+ return [];
651
+ await this.#operationChain;
652
+ const hits = await (await this.#getStore()).vsearch(query, {
653
+ collection: collections,
654
+ limit,
655
+ minScore,
656
+ expand: false,
657
+ });
658
+ const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
659
+ const candidates = new Map();
660
+ for (const hit of hits) {
661
+ const safe = parseSafeVirtualPath(hit.file, this.#sources);
662
+ if (!safe || safe.source.kind !== "skills")
663
+ continue;
664
+ const path = realpathSync(resolve(safe.source.root, safe.relativePath));
665
+ if (basename(path).toLowerCase() !== "skill.md")
666
+ continue;
667
+ const frontmatter = /^---\s*\n([\s\S]*?)\n---(?:\n|$)/u.exec(hit.body)?.[1];
668
+ const configuredName = frontmatter?.split("\n")
669
+ .map((line) => /^name:\s*(.+?)\s*$/u.exec(line)?.[1])
670
+ .find((name) => name !== undefined)
671
+ ?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2");
672
+ const candidate = {
673
+ name: configuredName?.trim() || basename(dirname(path)),
674
+ path,
675
+ score: hit.score,
676
+ };
677
+ const key = candidate.name.toLowerCase();
678
+ const order = sourceOrder.get(safe.source.collection) ?? Number.MAX_SAFE_INTEGER;
679
+ const current = candidates.get(key);
680
+ if (!current || order < current.sourceOrder ||
681
+ (order === current.sourceOrder && candidate.score > current.candidate.score)) {
682
+ candidates.set(key, { candidate, sourceOrder: order });
683
+ }
684
+ }
685
+ return [...candidates.values()]
686
+ .map(({ candidate }) => candidate)
687
+ .sort((left, right) => right.score - left.score)
688
+ .slice(0, limit);
689
+ }
592
690
  async readFile(params) {
593
691
  const safe = parseSafeVirtualPath(params.relPath, this.#sources);
594
- if (!safe)
692
+ if (!safe || safe.source.kind === "skills") {
595
693
  return { status: "not_found", text: "", path: params.relPath };
694
+ }
596
695
  await this.#operationChain;
597
696
  const store = await this.#getStore();
598
697
  const doc = await store.get(safe.normalized);
@@ -628,7 +727,11 @@ export class QmdMemoryManager {
628
727
  custom: {
629
728
  corpora: [...corpora].map(([name, sources]) => sources[0]?.kind === "sessions"
630
729
  ? { name, kind: "sessions", chatTypes: sources[0].chatTypes }
631
- : { name, kind: "files", paths: sources.map((source) => source.configuredPath) }),
730
+ : {
731
+ name,
732
+ kind: sources[0]?.kind === "skills" ? "skills" : "files",
733
+ paths: sources.map((source) => source.configuredPath),
734
+ }),
632
735
  ...(this.#watchError ? { watchError: this.#watchError } : {}),
633
736
  },
634
737
  };
@@ -3,6 +3,7 @@ import { Value } from "typebox/value";
3
3
  import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
4
4
  import { resolveConfig } from "./config.js";
5
5
  import { QmdMemoryRuntime } from "./runtime.js";
6
+ import { registerSkillWhisperer } from "./skill-whisperer.js";
6
7
  function getContext(ctx) {
7
8
  const cfg = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
8
9
  if (!cfg || !ctx.agentId)
@@ -43,7 +44,7 @@ function createSearchTool(runtime, ctx) {
43
44
  return {
44
45
  name: "memory_search",
45
46
  label: "Memory Search",
46
- description: "Search configured Markdown corpora with semantic vector retrieval. Omit corpora to search all of them.",
47
+ description: "Search configured memory corpora with semantic vector retrieval. The isolated skills corpus is never included.",
47
48
  parameters: searchParameters,
48
49
  async execute(_toolCallId, params, signal) {
49
50
  const { query: untrimmedQuery, corpora, sessionFilter, maxResults, minScore } = Value.Parse(searchParameters, params);
@@ -277,7 +278,7 @@ function createUpdateMaintenanceTool(runtime, ctx) {
277
278
  return {
278
279
  name: "memory_update_maintenance_task",
279
280
  label: "Update Memory Maintenance Task",
280
- description: "Resolve, defer, or dismiss a memory-maintenance proposal. This tool never edits source Markdown.",
281
+ description: "Resolve completed work, defer outstanding work, or dismiss an irrelevant memory-maintenance proposal. This tool never edits source Markdown.",
281
282
  parameters: updateMaintenanceParameters,
282
283
  async execute(_toolCallId, params) {
283
284
  const { taskId, action, note, annotation } = Value.Parse(updateMaintenanceParameters, params);
@@ -380,6 +381,7 @@ export function registerUnblockMemory(api) {
380
381
  runtime,
381
382
  };
382
383
  api.registerMemoryCapability(capability);
384
+ registerSkillWhisperer(api, runtime, config.skillWhisperer);
383
385
  api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
384
386
  api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
385
387
  api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), { names: ["memory_sync_sessions"] });
@@ -68,5 +68,13 @@ export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
68
68
  agentId: string;
69
69
  }): Promise<void>;
70
70
  closeAllMemorySearchManagers(): Promise<void>;
71
+ searchSkills(params: {
72
+ cfg: OpenClawConfig;
73
+ agentId: string;
74
+ }, query: string, minScore: number, limit: number): Promise<import("./manager.js").SkillSearchCandidate[]>;
75
+ resolveSkillPath(params: {
76
+ cfg: OpenClawConfig;
77
+ agentId: string;
78
+ }, path: string): string | undefined;
71
79
  }
72
80
  export {};
@@ -5,7 +5,7 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveStateDir, } from "ope
5
5
  import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
6
6
  import { QmdMemoryManager } from "./manager.js";
7
7
  import { resolveTimezone } from "./session-projector.js";
8
- import { resolveSessionSource, resolveSources } from "./sources.js";
8
+ import { resolveConfiguredSkillPath, resolveSessionSource, resolveSources } from "./sources.js";
9
9
  import { classifyWorkspaceMemoryPaths } from "./workspace-path-classifier.js";
10
10
  const activeSessionSyncs = new Map();
11
11
  async function readJson(path) {
@@ -190,10 +190,21 @@ export class QmdMemoryRuntime {
190
190
  this.#managers.clear();
191
191
  await Promise.all(managers.map(async (pending) => (await pending).close()));
192
192
  }
193
+ async searchSkills(params, query, minScore, limit) {
194
+ const { manager, error } = await this.getMemorySearchManager(params);
195
+ if (!manager)
196
+ throw new Error(error ?? "memory unavailable");
197
+ return manager.searchSkills(query, minScore, limit);
198
+ }
199
+ resolveSkillPath(params, path) {
200
+ const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
201
+ const skillCorpora = this.#corpora.filter((corpus) => corpus.kind === "skills");
202
+ return resolveConfiguredSkillPath(workspaceDir, path, resolveSources(workspaceDir, skillCorpora));
203
+ }
193
204
  async #createManager(cfg, agentId) {
194
205
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
195
206
  const stateDir = join(this.#stateRoot, "agents", agentId, "unblock-memory");
196
- const fileCorpora = this.#corpora.filter((corpus) => corpus.kind === "files");
207
+ const fileCorpora = this.#corpora.filter((corpus) => corpus.kind === "files" || corpus.kind === "skills");
197
208
  const sessionCorpus = this.#corpora.find((corpus) => corpus.kind === "sessions");
198
209
  const sources = resolveSources(workspaceDir, fileCorpora);
199
210
  const sessionSource = sessionCorpus
@@ -0,0 +1,16 @@
1
+ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { UnblockMemoryConfig } from "./config.js";
3
+ import type { SkillSearchCandidate } from "./manager.js";
4
+ type SkillWhispererRuntime = {
5
+ searchSkills(params: {
6
+ cfg: OpenClawConfig;
7
+ agentId: string;
8
+ }, query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
9
+ resolveSkillPath(params: {
10
+ cfg: OpenClawConfig;
11
+ agentId: string;
12
+ }, path: string): string | undefined;
13
+ };
14
+ export declare function buildSkillWhispererQuery(prompt: string, messages: readonly unknown[], historyMessages: number): string;
15
+ export declare function registerSkillWhisperer(api: OpenClawPluginApi, runtime: SkillWhispererRuntime, config: UnblockMemoryConfig["skillWhisperer"]): void;
16
+ export {};
@@ -0,0 +1,113 @@
1
+ import { basename } from "node:path";
2
+ const CANDIDATE_LIMIT = 10;
3
+ const MAX_QUERY_CHARS = 12_000;
4
+ function isRecord(value) {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+ function messageText(message) {
8
+ if (!isRecord(message) || (message.role !== "user" && message.role !== "assistant"))
9
+ return undefined;
10
+ if (typeof message.content === "string") {
11
+ const text = message.content.trim();
12
+ return text ? { role: message.role, text } : undefined;
13
+ }
14
+ if (!Array.isArray(message.content))
15
+ return undefined;
16
+ const text = message.content.flatMap((part) => {
17
+ return isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [];
18
+ }).join("\n").trim();
19
+ return text ? { role: message.role, text } : undefined;
20
+ }
21
+ export function buildSkillWhispererQuery(prompt, messages, historyMessages) {
22
+ const availableHistory = messages.flatMap((message) => {
23
+ const parsed = messageText(message);
24
+ return parsed ? [`${parsed.role}: ${parsed.text}`] : [];
25
+ });
26
+ const history = historyMessages === 0 ? [] : availableHistory.slice(-historyMessages);
27
+ return [...history, `user: ${prompt.trim()}`].join("\n\n").slice(-MAX_QUERY_CHARS);
28
+ }
29
+ function readPath(params) {
30
+ for (const value of [params.path, params.file_path, params.filePath]) {
31
+ if (typeof value === "string" && basename(value).toLowerCase() === "skill.md")
32
+ return value;
33
+ }
34
+ return undefined;
35
+ }
36
+ function sessionScope(context) {
37
+ return context.sessionId || context.sessionKey;
38
+ }
39
+ export function registerSkillWhisperer(api, runtime, config) {
40
+ if (!config.enabled)
41
+ return;
42
+ const sessions = new Map();
43
+ const stateFor = (scope) => {
44
+ let state = sessions.get(scope);
45
+ if (!state) {
46
+ state = { turn: 0, skills: new Map() };
47
+ sessions.set(scope, state);
48
+ }
49
+ return state;
50
+ };
51
+ const active = (agentId) => ({ cfg: api.config, agentId });
52
+ api.on("before_prompt_build", async (event, context) => {
53
+ const scope = sessionScope(context);
54
+ if (context.trigger !== "user" || !scope || !context.runId || !context.agentId)
55
+ return;
56
+ const state = stateFor(scope);
57
+ if (state.lastRunId === context.runId)
58
+ return;
59
+ state.lastRunId = context.runId;
60
+ state.turn += 1;
61
+ try {
62
+ const candidates = await runtime.searchSkills(active(context.agentId), buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), config.minScore, CANDIDATE_LIMIT);
63
+ const selected = candidates.find((candidate) => {
64
+ if (candidate.score < config.minScore)
65
+ return false;
66
+ const history = state.skills.get(candidate.path);
67
+ const lastSeen = Math.max(history?.suggested ?? -Infinity, history?.opened ?? -Infinity);
68
+ return state.turn - lastSeen > config.cooldownTurns;
69
+ });
70
+ if (!selected)
71
+ return;
72
+ const history = state.skills.get(selected.path) ?? {};
73
+ history.suggested = state.turn;
74
+ state.skills.set(selected.path, history);
75
+ return {
76
+ prependContext: `A potentially relevant skill is available: ${JSON.stringify(selected.name)} ` +
77
+ `at ${JSON.stringify(selected.path)}. Check it before proceeding if applicable.`,
78
+ };
79
+ }
80
+ catch (error) {
81
+ api.logger.warn(`unblock-memory skill whisperer search failed: ${String(error)}`);
82
+ return;
83
+ }
84
+ });
85
+ api.on("after_tool_call", (event, context) => {
86
+ if (event.toolName !== "read" || event.error ||
87
+ (isRecord(event.result) && event.result.isError === true) || !context.agentId)
88
+ return;
89
+ const scope = sessionScope(context);
90
+ const path = scope ? readPath(event.params) : undefined;
91
+ if (!scope || !path)
92
+ return;
93
+ try {
94
+ const canonicalPath = runtime.resolveSkillPath(active(context.agentId), path);
95
+ if (!canonicalPath)
96
+ return;
97
+ const state = stateFor(scope);
98
+ const history = state.skills.get(canonicalPath) ?? {};
99
+ history.opened = state.turn;
100
+ state.skills.set(canonicalPath, history);
101
+ }
102
+ catch (error) {
103
+ api.logger.warn(`unblock-memory skill whisperer read tracking failed: ${String(error)}`);
104
+ }
105
+ }, { matcher: ["read"] });
106
+ api.on("session_end", (event, context) => {
107
+ sessions.delete(event.sessionId);
108
+ if (event.sessionKey)
109
+ sessions.delete(event.sessionKey);
110
+ if (context.sessionKey)
111
+ sessions.delete(context.sessionKey);
112
+ });
113
+ }
@@ -1,9 +1,9 @@
1
- import type { ChatType, FileCorpusConfig } from "./config.js";
1
+ import type { ChatType, FileCorpusConfig, SkillCorpusConfig } from "./config.js";
2
2
  export type ResolvedSource = {
3
3
  collection: string;
4
4
  corpus: string;
5
5
  configuredPath: string;
6
- kind: "files" | "sessions";
6
+ kind: "files" | "skills" | "sessions";
7
7
  root: string;
8
8
  pattern: string;
9
9
  watchPath: string;
@@ -11,7 +11,8 @@ export type ResolvedSource = {
11
11
  };
12
12
  export declare function resolveSource(workspaceDir: string, configuredPath: string, corpus?: string): ResolvedSource;
13
13
  export declare function resolveSessionSource(sessionsDir: string, chatTypes: readonly ChatType[]): ResolvedSource;
14
- export declare function resolveSources(workspaceDir: string, corpora: readonly FileCorpusConfig[]): ResolvedSource[];
14
+ export declare function resolveSources(workspaceDir: string, corpora: readonly (FileCorpusConfig | SkillCorpusConfig)[]): ResolvedSource[];
15
+ export declare function resolveConfiguredSkillPath(workspaceDir: string, inputPath: string, sources: readonly ResolvedSource[]): string | undefined;
15
16
  export declare function parseSafeVirtualPath(virtualPath: string, sources: ReadonlyMap<string, ResolvedSource>): {
16
17
  source: ResolvedSource;
17
18
  relativePath: string;
@@ -68,6 +68,9 @@ export function resolveSource(workspaceDir, configuredPath, corpus = "memory") {
68
68
  assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
69
69
  return { collection: collectionName(absolute), corpus, configuredPath, kind: "files", root, pattern, watchPath: root };
70
70
  }
71
+ function resolveFileSource(workspaceDir, configuredPath, corpus) {
72
+ return { ...resolveSource(workspaceDir, configuredPath, corpus.name), kind: corpus.kind };
73
+ }
71
74
  export function resolveSessionSource(sessionsDir, chatTypes) {
72
75
  return {
73
76
  ...resolveSource(sessionsDir, sessionsDir, "sessions"),
@@ -81,7 +84,7 @@ export function resolveSources(workspaceDir, corpora) {
81
84
  const configured = new Map();
82
85
  for (const corpus of corpora) {
83
86
  for (const path of corpus.paths) {
84
- const source = resolveSource(workspaceDir, path, corpus.name);
87
+ const source = resolveFileSource(workspaceDir, path, corpus);
85
88
  const identity = `${source.root}\0${source.pattern}`;
86
89
  const duplicate = configured.get(identity);
87
90
  if (duplicate) {
@@ -94,6 +97,36 @@ export function resolveSources(workspaceDir, corpora) {
94
97
  }
95
98
  return sources;
96
99
  }
100
+ export function resolveConfiguredSkillPath(workspaceDir, inputPath, sources) {
101
+ if (basename(inputPath).toLowerCase() !== "skill.md")
102
+ return undefined;
103
+ const target = resolve(isAbsolute(expandHome(inputPath))
104
+ ? expandHome(inputPath)
105
+ : resolve(workspaceDir, inputPath));
106
+ let canonicalTarget;
107
+ try {
108
+ canonicalTarget = realpathSync(target);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ for (const source of sources) {
114
+ if (source.kind !== "skills")
115
+ continue;
116
+ let canonicalRoot;
117
+ try {
118
+ canonicalRoot = realpathSync(source.root);
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ const relativePath = relative(canonicalRoot, canonicalTarget);
124
+ const safe = parseSafeVirtualPath(`qmd://${source.collection}/${relativePath.split(sep).join("/")}`, new Map([[source.collection, source]]));
125
+ if (safe)
126
+ return canonicalTarget;
127
+ }
128
+ return undefined;
129
+ }
97
130
  export function parseSafeVirtualPath(virtualPath, sources) {
98
131
  const match = /^qmd:\/\/([^/]+)\/(.+)$/.exec(virtualPath.trim());
99
132
  if (!match)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.2.7",
4
+ "version": "0.3.0",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
@@ -23,7 +23,11 @@
23
23
  },
24
24
  "corpora": {
25
25
  "label": "Memory Corpora",
26
- "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace."
26
+ "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace. Skill files use the isolated skills corpus."
27
+ },
28
+ "skillWhisperer.enabled": {
29
+ "label": "Skill Whisperer",
30
+ "help": "Suggest at most one semantically relevant configured skill before a user turn. Requires hook conversation access."
27
31
  },
28
32
  "analysis.executable": {
29
33
  "label": "Memory Analysis Worker",
@@ -71,6 +75,20 @@
71
75
  "default": ["channel", "group"]
72
76
  }
73
77
  }
78
+ },
79
+ {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "required": ["name", "kind", "paths"],
83
+ "properties": {
84
+ "name": { "const": "skills" },
85
+ "kind": { "const": "skills" },
86
+ "paths": {
87
+ "type": "array",
88
+ "minItems": 1,
89
+ "items": { "type": "string", "pattern": "\\S" }
90
+ }
91
+ }
74
92
  }
75
93
  ]
76
94
  },
@@ -88,6 +106,22 @@
88
106
  "properties": {
89
107
  "executable": { "type": "string", "minLength": 1 }
90
108
  }
109
+ },
110
+ "skillWhisperer": {
111
+ "type": "object",
112
+ "additionalProperties": false,
113
+ "properties": {
114
+ "enabled": { "type": "boolean", "default": false },
115
+ "historyMessages": { "type": "integer", "minimum": 0, "default": 5 },
116
+ "minScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.4 },
117
+ "cooldownTurns": { "type": "integer", "minimum": 0, "default": 10 }
118
+ },
119
+ "default": {
120
+ "enabled": false,
121
+ "historyMessages": 5,
122
+ "minScore": 0.4,
123
+ "cooldownTurns": 10
124
+ }
91
125
  }
92
126
  }
93
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -74,8 +74,10 @@ rigid document template.
74
74
  attach a chunk or document date only when supported; otherwise defer or mark
75
75
  it irrelevant. For exact-duplicate proposals, decide whether cleanup should
76
76
  be proposed, but do not treat repetition across historical files as an error.
77
- The maintenance tools never change source Markdown, and generated session
78
- projections must never be manually cleaned.
77
+ Mark intentional repetition `irrelevant`, and keep accidental duplication
78
+ `deferred` until the source is actually cleaned. Mark it `resolved` only after
79
+ that cleanup is complete. The maintenance tools never change source Markdown,
80
+ and generated session projections must never be manually cleaned.
79
81
  - Verify an updated file with `memory_search`, using
80
82
  `corpora: ["knowledge"]`, and check all-corpora ranking when useful.
81
83
  - Report the questions investigated, evidence consulted beyond each cluster,