@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 +10 -9
- package/dist/src/config.js +2 -2
- package/dist/src/manager.js +87 -34
- package/dist/src/skill-whisperer.js +6 -8
- package/openclaw.plugin.json +3 -3
- package/package.json +28 -8
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.
|
|
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,
|
|
115
|
-
|
|
116
|
-
skill
|
|
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.
|
|
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
|
|
121
|
-
|
|
122
|
-
per session and intentionally resets with
|
|
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`
|
package/dist/src/config.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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");
|
package/dist/src/manager.js
CHANGED
|
@@ -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
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
686
|
-
.
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
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.
|
|
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": [
|
|
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": {
|
|
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": [
|
|
56
|
-
|
|
57
|
-
|
|
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": {
|
|
80
|
+
"release": {
|
|
81
|
+
"publishToClawHub": false,
|
|
82
|
+
"publishToNpm": true
|
|
83
|
+
}
|
|
64
84
|
}
|
|
65
85
|
}
|