@unblocklabs/unblock-memory 0.3.0 → 0.3.1

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,16 +111,17 @@ 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`
@@ -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");
@@ -10,6 +10,39 @@ const DEFAULT_READ_LINES = 120;
10
10
  const MAX_READ_CHARS = 12_000;
11
11
  const WATCH_DEBOUNCE_MS = 250;
12
12
  const qmdModule = import("@unblocklabs/qmd");
13
+ function frontmatterValue(body, key) {
14
+ const frontmatter = /^---\s*\n([\s\S]*?)\n---(?:\n|$)/u.exec(body)?.[1];
15
+ const raw = frontmatter?.split("\n")
16
+ .map((line) => new RegExp(`^${key}:\\s*(.+?)\\s*$`, "u").exec(line)?.[1])
17
+ .find((value) => value !== undefined);
18
+ return raw?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2").trim();
19
+ }
20
+ function embeddingText(name, description, model) {
21
+ return model.toLowerCase().includes("qwen3-embedding")
22
+ ? `${name}\n${description}`
23
+ : `title: ${name} | text: ${description}`;
24
+ }
25
+ function queryText(query, model) {
26
+ return model.toLowerCase().includes("qwen3-embedding")
27
+ ? `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`
28
+ : `task: search result | query: ${query}`;
29
+ }
30
+ function cosineSimilarity(left, right) {
31
+ if (left.length !== right.length || left.length === 0)
32
+ return 0;
33
+ let dot = 0;
34
+ let leftMagnitude = 0;
35
+ let rightMagnitude = 0;
36
+ for (let index = 0; index < left.length; index += 1) {
37
+ const leftValue = left[index];
38
+ const rightValue = right[index];
39
+ dot += leftValue * rightValue;
40
+ leftMagnitude += leftValue * leftValue;
41
+ rightMagnitude += rightValue * rightValue;
42
+ }
43
+ const denominator = Math.sqrt(leftMagnitude * rightMagnitude);
44
+ return denominator === 0 ? 0 : dot / denominator;
45
+ }
13
46
  function markStaleForAnalysisCollectionChange(db, collections, hasSkills) {
14
47
  const current = collections.toSorted();
15
48
  const previous = latestAnalysisCollections(db)?.toSorted();
@@ -166,6 +199,7 @@ export class QmdMemoryManager {
166
199
  #dirty = true;
167
200
  #sessionMetadata = new Map();
168
201
  #sessionManifestMtimeNs;
202
+ #skillIndex;
169
203
  constructor(params) {
170
204
  this.#dbPath = params.dbPath;
171
205
  this.#curationPath = params.curationPath ?? `${params.dbPath}.curation.sqlite`;
@@ -363,6 +397,8 @@ export class QmdMemoryManager {
363
397
  const update = await store.update({ collections: [source.collection] });
364
398
  this.#cleanupRemovedDocuments?.(update.updated + update.removed);
365
399
  const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
400
+ if (source.kind === "skills" && (changed || params?.force === true))
401
+ this.#skillIndex = undefined;
366
402
  if (source.kind !== "skills" && (changed || params?.force === true))
367
403
  markAnalysisStale();
368
404
  const embed = await store.embed({
@@ -649,41 +685,58 @@ export class QmdMemoryManager {
649
685
  if (collections.length === 0)
650
686
  return [];
651
687
  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 });
688
+ const store = await this.#getStore();
689
+ if (!store.internal?.llm)
690
+ throw new Error("Skill Whisperer requires the QMD embedding model");
691
+ const llm = store.internal.llm;
692
+ this.#skillIndex ??= (async () => {
693
+ const placeholders = collections.map(() => "?").join(", ");
694
+ const rows = store.internal.db.prepare(`
695
+ SELECT document.collection, document.path, content.doc AS body
696
+ FROM documents document
697
+ JOIN content ON content.hash = document.hash
698
+ WHERE document.active = 1 AND document.collection IN (${placeholders})
699
+ ORDER BY document.collection, document.path
700
+ `).all(...collections);
701
+ const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
702
+ const metadata = new Map();
703
+ for (const row of rows) {
704
+ const file = `qmd://${row.collection}/${row.path}`;
705
+ const safe = parseSafeVirtualPath(file, this.#sources);
706
+ if (!safe || safe.source.kind !== "skills")
707
+ continue;
708
+ const path = realpathSync(resolve(safe.source.root, safe.relativePath));
709
+ if (basename(path).toLowerCase() !== "skill.md")
710
+ continue;
711
+ const name = frontmatterValue(row.body, "name") || basename(dirname(path));
712
+ const description = frontmatterValue(row.body, "description") ?? "";
713
+ const key = name.toLowerCase();
714
+ const order = sourceOrder.get(row.collection) ?? Number.MAX_SAFE_INTEGER;
715
+ const current = metadata.get(key);
716
+ if (!current || order < current.sourceOrder) {
717
+ metadata.set(key, { candidate: { name, path }, description, sourceOrder: order });
718
+ }
683
719
  }
684
- }
685
- return [...candidates.values()]
686
- .map(({ candidate }) => candidate)
720
+ const skills = [...metadata.values()];
721
+ const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => embeddingText(candidate.name, description, llm.embedModelName)));
722
+ return skills.flatMap(({ candidate }, index) => {
723
+ const embedding = embeddings[index]?.embedding;
724
+ return embedding ? [{ ...candidate, score: 0, embedding }] : [];
725
+ });
726
+ })().catch((error) => {
727
+ this.#skillIndex = undefined;
728
+ throw error;
729
+ });
730
+ const queryEmbedding = await llm.embed(queryText(query, llm.embedModelName), { isQuery: true });
731
+ if (!queryEmbedding)
732
+ return [];
733
+ const candidates = await this.#skillIndex;
734
+ return candidates
735
+ .map(({ embedding, ...candidate }) => ({
736
+ ...candidate,
737
+ score: cosineSimilarity(queryEmbedding.embedding, embedding),
738
+ }))
739
+ .filter((candidate) => candidate.score >= minScore)
687
740
  .sort((left, right) => right.score - left.score)
688
741
  .slice(0, limit);
689
742
  }
@@ -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;
@@ -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.1",
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.1",
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
  }