@unblocklabs/unblock-memory 0.3.0 → 0.3.2

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
@@ -76,7 +76,7 @@ directories, or globs into named corpora:
76
76
  skillWhisperer: {
77
77
  enabled: false,
78
78
  historyMessages: 5,
79
- minScore: 0.4,
79
+ minScore: 0.5,
80
80
  cooldownTurns: 10,
81
81
  },
82
82
  // Optional: omit unless the local analysis worker is installed.
@@ -111,22 +111,25 @@ Skill Whisperer is an optional semantic reminder for user turns. Configure one
111
111
  isolated `skills` corpus, set `skillWhisperer.enabled` to `true`, and authorize
112
112
  `plugins.entries.unblock-memory.hooks.allowConversationAccess`. The feature
113
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.
114
+ messages, compares it with each configured skill's frontmatter `name` and
115
+ `description`, and prepends at most one name/path hint when the best match
116
+ reaches `minScore`. Full skill procedures do not influence routing. The plugin
117
+ never opens or invokes a skill automatically.
117
118
 
118
- The defaults use five prior messages, a calibrated score threshold of `0.4`,
119
+ The defaults use five prior messages, a calibrated score threshold of `0.5`,
119
120
  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.
121
+ successful direct `read` of its indexed `SKILL.md`. When the best qualifying
122
+ skill is cooling down, no hint is emitted; Skill Whisperer does not fall through
123
+ to a weaker match. Cooldown state is per session and intentionally resets with
124
+ the Gateway. Shell-command reads are not tracked.
124
125
 
125
126
  The `skills` corpus shares the existing QMD store and warm embedding model but
126
127
  is private to Skill Whisperer: it is excluded from ordinary `memory_search`
127
128
  (including `corpora: ["all"]`), `memory_get`, clustering, and memory-maintenance
128
129
  tasks. Paths are explicit by design; the plugin does not reconstruct
129
- OpenClaw's effective skill inventory from `openclaw.json`.
130
+ OpenClaw's effective skill inventory from `openclaw.json`. Configured skill
131
+ globs follow symlinked directories, including OpenClaw's `plugin-skills`
132
+ directory.
130
133
 
131
134
  Use `sessionFilter` to restrict session results by metadata while leaving file
132
135
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
@@ -9,7 +9,7 @@ export const DEFAULT_CORPORA = [{
9
9
  const DEFAULT_SKILL_WHISPERER = {
10
10
  enabled: false,
11
11
  historyMessages: 5,
12
- minScore: 0.4,
12
+ minScore: 0.5,
13
13
  cooldownTurns: 10,
14
14
  };
15
15
  function assertOnlyKeys(value, allowed, label) {
@@ -126,7 +126,7 @@ export function resolveConfig(value) {
126
126
  assertOnlyKeys(value, ["enabled", "historyMessages", "minScore", "cooldownTurns"], "skillWhisperer");
127
127
  const enabled = value.enabled ?? false;
128
128
  const historyMessages = value.historyMessages ?? 5;
129
- const minScore = value.minScore ?? 0.4;
129
+ const minScore = value.minScore ?? 0.5;
130
130
  const cooldownTurns = value.cooldownTurns ?? 10;
131
131
  if (typeof enabled !== "boolean")
132
132
  throw new Error("unblock-memory skillWhisperer.enabled must be a boolean");
@@ -1,7 +1,8 @@
1
- import { realpathSync } from "node:fs";
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
- import { basename, dirname, resolve } from "node:path";
3
+ import { basename, dirname, relative, resolve, sep } from "node:path";
4
4
  import chokidar from "chokidar";
5
+ import picomatch from "picomatch";
5
6
  import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
6
7
  import { CurationStore, chunkFingerprint, } from "./curation.js";
7
8
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
@@ -10,6 +11,83 @@ const DEFAULT_READ_LINES = 120;
10
11
  const MAX_READ_CHARS = 12_000;
11
12
  const WATCH_DEBOUNCE_MS = 250;
12
13
  const qmdModule = import("@unblocklabs/qmd");
14
+ function readSkillDocuments(source) {
15
+ const documents = [];
16
+ const visitedDirectories = new Set();
17
+ const visit = (directory) => {
18
+ let canonicalDirectory;
19
+ try {
20
+ canonicalDirectory = realpathSync(directory);
21
+ }
22
+ catch {
23
+ return;
24
+ }
25
+ if (visitedDirectories.has(canonicalDirectory))
26
+ return;
27
+ visitedDirectories.add(canonicalDirectory);
28
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
29
+ const path = resolve(directory, entry.name);
30
+ let kind = entry.isDirectory()
31
+ ? "directory"
32
+ : entry.isFile()
33
+ ? "file"
34
+ : undefined;
35
+ if (entry.isSymbolicLink()) {
36
+ try {
37
+ const target = statSync(path);
38
+ kind = target.isDirectory() ? "directory" : target.isFile() ? "file" : undefined;
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ }
44
+ if (kind === "directory") {
45
+ visit(path);
46
+ continue;
47
+ }
48
+ const relativePath = relative(source.root, path).split(sep).join("/");
49
+ if (kind !== "file" || basename(path).toLowerCase() !== "skill.md" ||
50
+ !picomatch.isMatch(relativePath, source.pattern, { dot: true }))
51
+ continue;
52
+ documents.push({ path, body: readFileSync(path, "utf8") });
53
+ }
54
+ };
55
+ visit(source.root);
56
+ return documents;
57
+ }
58
+ function frontmatterValue(body, key) {
59
+ const frontmatter = /^---\s*\n([\s\S]*?)\n---(?:\n|$)/u.exec(body)?.[1];
60
+ const raw = frontmatter?.split("\n")
61
+ .map((line) => new RegExp(`^${key}:\\s*(.+?)\\s*$`, "u").exec(line)?.[1])
62
+ .find((value) => value !== undefined);
63
+ return raw?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2").trim();
64
+ }
65
+ function embeddingText(name, description, model) {
66
+ return model.toLowerCase().includes("qwen3-embedding")
67
+ ? `${name}\n${description}`
68
+ : `title: ${name} | text: ${description}`;
69
+ }
70
+ function queryText(query, model) {
71
+ return model.toLowerCase().includes("qwen3-embedding")
72
+ ? `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`
73
+ : `task: search result | query: ${query}`;
74
+ }
75
+ function cosineSimilarity(left, right) {
76
+ if (left.length !== right.length || left.length === 0)
77
+ return 0;
78
+ let dot = 0;
79
+ let leftMagnitude = 0;
80
+ let rightMagnitude = 0;
81
+ for (let index = 0; index < left.length; index += 1) {
82
+ const leftValue = left[index];
83
+ const rightValue = right[index];
84
+ dot += leftValue * rightValue;
85
+ leftMagnitude += leftValue * leftValue;
86
+ rightMagnitude += rightValue * rightValue;
87
+ }
88
+ const denominator = Math.sqrt(leftMagnitude * rightMagnitude);
89
+ return denominator === 0 ? 0 : dot / denominator;
90
+ }
13
91
  function markStaleForAnalysisCollectionChange(db, collections, hasSkills) {
14
92
  const current = collections.toSorted();
15
93
  const previous = latestAnalysisCollections(db)?.toSorted();
@@ -166,6 +244,7 @@ export class QmdMemoryManager {
166
244
  #dirty = true;
167
245
  #sessionMetadata = new Map();
168
246
  #sessionManifestMtimeNs;
247
+ #skillIndex;
169
248
  constructor(params) {
170
249
  this.#dbPath = params.dbPath;
171
250
  this.#curationPath = params.curationPath ?? `${params.dbPath}.curation.sqlite`;
@@ -230,9 +309,11 @@ export class QmdMemoryManager {
230
309
  this.#watcher.on("error", (error) => {
231
310
  this.#watchError = error instanceof Error ? error.message : String(error);
232
311
  });
233
- this.#watcher.on("all", () => {
312
+ this.#watcher.on("all", (_event, path) => {
234
313
  if (this.#closed)
235
314
  return;
315
+ if (basename(path).toLowerCase() === "skill.md")
316
+ this.#skillIndex = undefined;
236
317
  this.#dirty = true;
237
318
  if (this.#watchTimer)
238
319
  clearTimeout(this.#watchTimer);
@@ -363,6 +444,8 @@ export class QmdMemoryManager {
363
444
  const update = await store.update({ collections: [source.collection] });
364
445
  this.#cleanupRemovedDocuments?.(update.updated + update.removed);
365
446
  const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
447
+ if (source.kind === "skills" && (changed || params?.force === true))
448
+ this.#skillIndex = undefined;
366
449
  if (source.kind !== "skills" && (changed || params?.force === true))
367
450
  markAnalysisStale();
368
451
  const embed = await store.embed({
@@ -649,41 +732,51 @@ export class QmdMemoryManager {
649
732
  if (collections.length === 0)
650
733
  return [];
651
734
  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 });
735
+ const store = await this.#getStore();
736
+ if (!store.internal?.llm)
737
+ throw new Error("Skill Whisperer requires the QMD embedding model");
738
+ const llm = store.internal.llm;
739
+ this.#skillIndex ??= (async () => {
740
+ const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
741
+ const metadata = new Map();
742
+ for (const source of this.#sources.values()) {
743
+ if (source.kind !== "skills")
744
+ continue;
745
+ for (const document of readSkillDocuments(source)) {
746
+ const name = frontmatterValue(document.body, "name") || basename(dirname(document.path));
747
+ const description = frontmatterValue(document.body, "description") ?? "";
748
+ const key = name.toLowerCase();
749
+ const order = sourceOrder.get(source.collection) ?? Number.MAX_SAFE_INTEGER;
750
+ const current = metadata.get(key);
751
+ if (!current || order < current.sourceOrder) {
752
+ metadata.set(key, {
753
+ candidate: { name, path: document.path },
754
+ description,
755
+ sourceOrder: order,
756
+ });
757
+ }
758
+ }
683
759
  }
684
- }
685
- return [...candidates.values()]
686
- .map(({ candidate }) => candidate)
760
+ const skills = [...metadata.values()];
761
+ const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => embeddingText(candidate.name, description, llm.embedModelName)));
762
+ return skills.flatMap(({ candidate }, index) => {
763
+ const embedding = embeddings[index]?.embedding;
764
+ return embedding ? [{ ...candidate, score: 0, embedding }] : [];
765
+ });
766
+ })().catch((error) => {
767
+ this.#skillIndex = undefined;
768
+ throw error;
769
+ });
770
+ const queryEmbedding = await llm.embed(queryText(query, llm.embedModelName), { isQuery: true });
771
+ if (!queryEmbedding)
772
+ return [];
773
+ const candidates = await this.#skillIndex;
774
+ return candidates
775
+ .map(({ embedding, ...candidate }) => ({
776
+ ...candidate,
777
+ score: cosineSimilarity(queryEmbedding.embedding, embedding),
778
+ }))
779
+ .filter((candidate) => candidate.score >= minScore)
687
780
  .sort((left, right) => right.score - left.score)
688
781
  .slice(0, limit);
689
782
  }
@@ -60,14 +60,12 @@ export function registerSkillWhisperer(api, runtime, config) {
60
60
  state.turn += 1;
61
61
  try {
62
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)
63
+ const selected = candidates[0];
64
+ if (!selected || selected.score < config.minScore)
65
+ return;
66
+ const previous = state.skills.get(selected.path);
67
+ const lastSeen = Math.max(previous?.suggested ?? -Infinity, previous?.opened ?? -Infinity);
68
+ if (state.turn - lastSeen <= config.cooldownTurns)
71
69
  return;
72
70
  const history = state.skills.get(selected.path) ?? {};
73
71
  history.suggested = state.turn;
@@ -113,16 +113,10 @@ export function resolveConfiguredSkillPath(workspaceDir, inputPath, sources) {
113
113
  for (const source of sources) {
114
114
  if (source.kind !== "skills")
115
115
  continue;
116
- let canonicalRoot;
117
- try {
118
- canonicalRoot = realpathSync(source.root);
119
- }
120
- catch {
116
+ const relativePath = relative(source.root, target);
117
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath))
121
118
  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)
119
+ if (picomatch.isMatch(relativePath.split(sep).join("/"), source.pattern, { dot: true }))
126
120
  return canonicalTarget;
127
121
  }
128
122
  return undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
@@ -113,13 +113,13 @@
113
113
  "properties": {
114
114
  "enabled": { "type": "boolean", "default": false },
115
115
  "historyMessages": { "type": "integer", "minimum": 0, "default": 5 },
116
- "minScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.4 },
116
+ "minScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 },
117
117
  "cooldownTurns": { "type": "integer", "minimum": 0, "default": 10 }
118
118
  },
119
119
  "default": {
120
120
  "enabled": false,
121
121
  "historyMessages": 5,
122
- "minScore": 0.4,
122
+ "minScore": 0.5,
123
123
  "cooldownTurns": 10
124
124
  }
125
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,12 @@
18
18
  "access": "public",
19
19
  "provenance": true
20
20
  },
21
- "files": ["dist", "skills", "README.md", "openclaw.plugin.json"],
21
+ "files": [
22
+ "dist",
23
+ "skills",
24
+ "README.md",
25
+ "openclaw.plugin.json"
26
+ ],
22
27
  "scripts": {
23
28
  "build": "tsc -p tsconfig.build.json",
24
29
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -48,18 +53,33 @@
48
53
  "openclaw": ">=2026.8.1-beta.3"
49
54
  },
50
55
  "peerDependenciesMeta": {
51
- "openclaw": { "optional": true }
56
+ "openclaw": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "engines": {
61
+ "node": ">=22.0.0"
52
62
  },
53
- "engines": { "node": ">=22.0.0" },
54
63
  "openclaw": {
55
- "extensions": ["./dist/index.js"],
56
- "compat": { "pluginApi": ">=2026.8.1-beta.3", "minGatewayVersion": "2026.8.1-beta.3" },
57
- "build": { "openclawVersion": "2026.8.1-beta.3", "pluginSdkVersion": "2026.8.1-beta.3" },
64
+ "extensions": [
65
+ "./dist/index.js"
66
+ ],
67
+ "compat": {
68
+ "pluginApi": ">=2026.8.1-beta.3",
69
+ "minGatewayVersion": "2026.8.1-beta.3"
70
+ },
71
+ "build": {
72
+ "openclawVersion": "2026.8.1-beta.3",
73
+ "pluginSdkVersion": "2026.8.1-beta.3"
74
+ },
58
75
  "install": {
59
76
  "npmSpec": "@unblocklabs/unblock-memory",
60
77
  "defaultChoice": "npm",
61
78
  "minHostVersion": ">=2026.8.1-beta.3"
62
79
  },
63
- "release": { "publishToClawHub": false, "publishToNpm": true }
80
+ "release": {
81
+ "publishToClawHub": false,
82
+ "publishToNpm": true
83
+ }
64
84
  }
65
85
  }