@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 +13 -10
- package/dist/src/config.js +2 -2
- package/dist/src/manager.js +130 -37
- package/dist/src/skill-whisperer.js +6 -8
- package/dist/src/sources.js +3 -9
- 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,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,
|
|
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`
|
|
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`
|
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
|
@@ -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
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
686
|
-
.
|
|
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
|
|
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/dist/src/sources.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
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;
|
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.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.
|
|
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.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": [
|
|
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
|
}
|