@unblocklabs/unblock-memory 0.2.7 → 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 +55 -7
- package/dist/src/analysis.d.ts +3 -0
- package/dist/src/analysis.js +21 -1
- package/dist/src/config.d.ts +12 -1
- package/dist/src/config.js +69 -15
- package/dist/src/manager.d.ts +6 -0
- package/dist/src/manager.js +184 -28
- package/dist/src/plugin.js +4 -2
- package/dist/src/runtime.d.ts +8 -0
- package/dist/src/runtime.js +13 -2
- package/dist/src/skill-whisperer.d.ts +16 -0
- package/dist/src/skill-whisperer.js +111 -0
- package/dist/src/sources.d.ts +4 -3
- package/dist/src/sources.js +34 -1
- package/openclaw.plugin.json +36 -2
- package/package.json +28 -8
- package/skills/memory-curator/SKILL.md +4 -2
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.5,
|
|
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,35 @@ 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, 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.
|
|
118
|
+
|
|
119
|
+
The defaults use five prior messages, a calibrated score threshold of `0.5`,
|
|
120
|
+
and a ten-turn cooldown. A skill is cooling down after either a suggestion or a
|
|
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.
|
|
125
|
+
|
|
126
|
+
The `skills` corpus shares the existing QMD store and warm embedding model but
|
|
127
|
+
is private to Skill Whisperer: it is excluded from ordinary `memory_search`
|
|
128
|
+
(including `corpora: ["all"]`), `memory_get`, clustering, and memory-maintenance
|
|
129
|
+
tasks. Paths are explicit by design; the plugin does not reconstruct
|
|
130
|
+
OpenClaw's effective skill inventory from `openclaw.json`.
|
|
131
|
+
|
|
87
132
|
Use `sessionFilter` to restrict session results by metadata while leaving file
|
|
88
133
|
corpora searchable. Supported fields are `startedFrom` and `startedTo`
|
|
89
134
|
(inclusive ISO 8601 timestamps), `provider`, `chatType`, `accountId`, and
|
|
@@ -145,9 +190,10 @@ python3 -m venv .venv
|
|
|
145
190
|
Set `analysis.executable` to the absolute path of
|
|
146
191
|
`bin/unblock-memory-analysis` in that checkout. One worker installation can
|
|
147
192
|
serve every agent on the host. The plugin invokes it directly with
|
|
148
|
-
`--db <the agent's known index path
|
|
149
|
-
`--config-json <clustering options>` payload.
|
|
150
|
-
executable, shell command, or
|
|
193
|
+
`--db <the agent's known index path>`, the plugin's non-skill collection IDs,
|
|
194
|
+
and, when requested, a validated `--config-json <clustering options>` payload.
|
|
195
|
+
Agents cannot choose a database, executable, collection, shell command, or
|
|
196
|
+
arbitrary arguments.
|
|
151
197
|
|
|
152
198
|
Without the worker, `memory_list_clusters` reports that memory has not been
|
|
153
199
|
analyzed and `memory_recluster` reports that analysis is unavailable. Ordinary
|
|
@@ -189,9 +235,11 @@ whole corpus for chores. Persisted exact-duplicate analysis can likewise create
|
|
|
189
235
|
review proposals for non-session Markdown. `memory_list_maintenance_tasks`
|
|
190
236
|
returns at most ten tasks, while `memory_update_maintenance_task` can resolve,
|
|
191
237
|
defer, or mark one irrelevant and optionally attach a supported event date.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
238
|
+
For duplicate proposals, defer confirmed cleanup until the source change is
|
|
239
|
+
complete, mark intentional repetition irrelevant, and resolve only completed
|
|
240
|
+
work. These tools never edit or delete source Markdown. Duplicate cleanup
|
|
241
|
+
remains a reviewed source change outside the maintenance tool, and generated
|
|
242
|
+
session projections must never be edited directly.
|
|
195
243
|
|
|
196
244
|
Member excerpts are capped at 2 KB each and 12 KB across a response; source
|
|
197
245
|
aliases are capped at five per member and 50 across a response. These budgets
|
package/dist/src/analysis.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/analysis.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/config.js
CHANGED
|
@@ -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.5,
|
|
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 {
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.5;
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
147
|
+
return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, skillWhisperer };
|
|
94
148
|
}
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/manager.js
CHANGED
|
@@ -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,48 @@ 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 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
|
+
}
|
|
46
|
+
function markStaleForAnalysisCollectionChange(db, collections, hasSkills) {
|
|
47
|
+
const current = collections.toSorted();
|
|
48
|
+
const previous = latestAnalysisCollections(db)?.toSorted();
|
|
49
|
+
if (previous
|
|
50
|
+
? previous.join("\0") !== current.join("\0")
|
|
51
|
+
: hasSkills && latestAnalysisRunId(db) !== undefined) {
|
|
52
|
+
markMemoryAnalysisStale(db);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
12
55
|
function completedEmbeddingCount(result) {
|
|
13
56
|
if (result.errors > 0) {
|
|
14
57
|
throw new Error(`QMD failed to embed ${result.errors} chunk${result.errors === 1 ? "" : "s"}`);
|
|
@@ -156,6 +199,7 @@ export class QmdMemoryManager {
|
|
|
156
199
|
#dirty = true;
|
|
157
200
|
#sessionMetadata = new Map();
|
|
158
201
|
#sessionManifestMtimeNs;
|
|
202
|
+
#skillIndex;
|
|
159
203
|
constructor(params) {
|
|
160
204
|
this.#dbPath = params.dbPath;
|
|
161
205
|
this.#curationPath = params.curationPath ?? `${params.dbPath}.curation.sqlite`;
|
|
@@ -204,7 +248,7 @@ export class QmdMemoryManager {
|
|
|
204
248
|
}
|
|
205
249
|
#startWatcher() {
|
|
206
250
|
const paths = [...new Set([...this.#sources.values()]
|
|
207
|
-
.filter((source) => source.kind
|
|
251
|
+
.filter((source) => source.kind !== "sessions")
|
|
208
252
|
.map((source) => source.watchPath))];
|
|
209
253
|
if (paths.length === 0 || this.#watcher)
|
|
210
254
|
return;
|
|
@@ -239,8 +283,10 @@ export class QmdMemoryManager {
|
|
|
239
283
|
if (this.#storeFactory) {
|
|
240
284
|
this.#store = await this.#storeFactory();
|
|
241
285
|
const store = this.#store;
|
|
242
|
-
if (store.internal)
|
|
286
|
+
if (store.internal) {
|
|
243
287
|
ensureMemoryAnalysisSchema(store.internal.db);
|
|
288
|
+
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
289
|
+
}
|
|
244
290
|
return this.#store;
|
|
245
291
|
}
|
|
246
292
|
const { createStore } = await qmdModule;
|
|
@@ -256,8 +302,26 @@ export class QmdMemoryManager {
|
|
|
256
302
|
});
|
|
257
303
|
enableSecureDelete(store);
|
|
258
304
|
ensureMemoryAnalysisSchema(store.internal.db);
|
|
259
|
-
|
|
260
|
-
|
|
305
|
+
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
306
|
+
const configuredCollections = new Set(this.#allCollectionNames());
|
|
307
|
+
const staleCollections = (await store.getStatus()).collections
|
|
308
|
+
.map((collection) => collection.name)
|
|
309
|
+
.filter((collection) => !configuredCollections.has(collection));
|
|
310
|
+
const appearsInAnalysis = store.internal.db.prepare(`
|
|
311
|
+
SELECT 1
|
|
312
|
+
FROM memory_analysis_memberships membership
|
|
313
|
+
JOIN documents document ON document.hash = membership.hash
|
|
314
|
+
WHERE membership.run_id = (
|
|
315
|
+
SELECT id FROM memory_analysis_runs
|
|
316
|
+
WHERE completed_at IS NOT NULL
|
|
317
|
+
ORDER BY completed_at DESC, created_at DESC, id DESC
|
|
318
|
+
LIMIT 1
|
|
319
|
+
) AND document.collection = ?
|
|
320
|
+
LIMIT 1
|
|
321
|
+
`);
|
|
322
|
+
const prunedAnalysisInput = staleCollections.some((collection) => appearsInAnalysis.get(collection));
|
|
323
|
+
const prunedDocuments = await pruneStaleCollections(store, configuredCollections);
|
|
324
|
+
if (prunedDocuments > 0 && prunedAnalysisInput)
|
|
261
325
|
markMemoryAnalysisStale(store.internal.db);
|
|
262
326
|
try {
|
|
263
327
|
await ensureSemanticChunking(store);
|
|
@@ -272,22 +336,36 @@ export class QmdMemoryManager {
|
|
|
272
336
|
this.#store = store;
|
|
273
337
|
return store;
|
|
274
338
|
}
|
|
339
|
+
#allCollectionNames() {
|
|
340
|
+
return [...this.#sources.keys()];
|
|
341
|
+
}
|
|
342
|
+
#analysisCollectionNames() {
|
|
343
|
+
return [...this.#sources.values()]
|
|
344
|
+
.filter((source) => source.kind !== "skills")
|
|
345
|
+
.map((source) => source.collection);
|
|
346
|
+
}
|
|
347
|
+
#skillCollectionNames() {
|
|
348
|
+
return [...this.#sources.values()]
|
|
349
|
+
.filter((source) => source.kind === "skills")
|
|
350
|
+
.map((source) => source.collection);
|
|
351
|
+
}
|
|
275
352
|
#collectionNames(corpora) {
|
|
353
|
+
const publicSources = [...this.#sources.values()].filter((source) => source.kind !== "skills");
|
|
276
354
|
if (corpora === undefined)
|
|
277
|
-
return
|
|
355
|
+
return publicSources.map((source) => source.collection);
|
|
278
356
|
if (corpora.length === 0)
|
|
279
357
|
throw new Error("memory_search corpora must not be empty");
|
|
280
358
|
const selected = new Set(corpora);
|
|
281
359
|
if (selected.has("all")) {
|
|
282
360
|
if (selected.size > 1)
|
|
283
361
|
throw new Error('memory_search corpus "all" must be used alone');
|
|
284
|
-
return
|
|
362
|
+
return publicSources.map((source) => source.collection);
|
|
285
363
|
}
|
|
286
|
-
const known = new Set(
|
|
364
|
+
const known = new Set(publicSources.map((source) => source.corpus));
|
|
287
365
|
const unknown = [...selected].find((corpus) => !known.has(corpus));
|
|
288
366
|
if (unknown)
|
|
289
367
|
throw new Error(`memory_search unknown corpus: ${unknown}`);
|
|
290
|
-
return
|
|
368
|
+
return publicSources
|
|
291
369
|
.filter((source) => selected.has(source.corpus))
|
|
292
370
|
.map((source) => source.collection);
|
|
293
371
|
}
|
|
@@ -295,29 +373,41 @@ export class QmdMemoryManager {
|
|
|
295
373
|
const run = async () => {
|
|
296
374
|
const store = await this.#getStore();
|
|
297
375
|
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
376
|
const analysisStore = store;
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
377
|
+
const collections = [...this.#sources.values()].filter((source) => source.kind !== "sessions");
|
|
378
|
+
let analysisMarkedStale = false;
|
|
379
|
+
const markAnalysisStale = () => {
|
|
380
|
+
if (analysisMarkedStale || !analysisStore.internal)
|
|
381
|
+
return;
|
|
308
382
|
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
383
|
+
analysisMarkedStale = true;
|
|
384
|
+
};
|
|
385
|
+
if (collections.length === 0) {
|
|
386
|
+
const update = await store.update({ collections: [] });
|
|
387
|
+
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
388
|
+
if (update.indexed + update.updated + update.removed > 0 ||
|
|
389
|
+
update.needsEmbedding > 0 || params?.force === true) {
|
|
390
|
+
markAnalysisStale();
|
|
391
|
+
}
|
|
392
|
+
const embed = await store.embed({ force: params?.force, chunkStrategy: "semantic" });
|
|
393
|
+
if (completedEmbeddingCount(embed) > 0)
|
|
394
|
+
markAnalysisStale();
|
|
309
395
|
}
|
|
310
|
-
|
|
311
|
-
|
|
396
|
+
for (const source of collections) {
|
|
397
|
+
const update = await store.update({ collections: [source.collection] });
|
|
398
|
+
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
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;
|
|
402
|
+
if (source.kind !== "skills" && (changed || params?.force === true))
|
|
403
|
+
markAnalysisStale();
|
|
312
404
|
const embed = await store.embed({
|
|
313
|
-
|
|
405
|
+
collection: source.collection,
|
|
314
406
|
force: params?.force,
|
|
315
407
|
chunkStrategy: "semantic",
|
|
316
408
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (!invalidatesAnalysis && chunksEmbedded > 0 && analysisStore.internal) {
|
|
320
|
-
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
409
|
+
if (source.kind !== "skills" && completedEmbeddingCount(embed) > 0)
|
|
410
|
+
markAnalysisStale();
|
|
321
411
|
}
|
|
322
412
|
const status = await store.getStatus();
|
|
323
413
|
const indexedCollections = await store.listCollections();
|
|
@@ -381,6 +471,7 @@ export class QmdMemoryManager {
|
|
|
381
471
|
await this.#analysisRunner({
|
|
382
472
|
executable: this.#analysisExecutable,
|
|
383
473
|
dbPath: this.#dbPath,
|
|
474
|
+
collections: this.#analysisCollectionNames(),
|
|
384
475
|
options,
|
|
385
476
|
signal,
|
|
386
477
|
});
|
|
@@ -589,10 +680,71 @@ export class QmdMemoryManager {
|
|
|
589
680
|
}];
|
|
590
681
|
});
|
|
591
682
|
}
|
|
683
|
+
async searchSkills(query, minScore, limit) {
|
|
684
|
+
const collections = this.#skillCollectionNames();
|
|
685
|
+
if (collections.length === 0)
|
|
686
|
+
return [];
|
|
687
|
+
await this.#operationChain;
|
|
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
|
+
}
|
|
719
|
+
}
|
|
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)
|
|
740
|
+
.sort((left, right) => right.score - left.score)
|
|
741
|
+
.slice(0, limit);
|
|
742
|
+
}
|
|
592
743
|
async readFile(params) {
|
|
593
744
|
const safe = parseSafeVirtualPath(params.relPath, this.#sources);
|
|
594
|
-
if (!safe)
|
|
745
|
+
if (!safe || safe.source.kind === "skills") {
|
|
595
746
|
return { status: "not_found", text: "", path: params.relPath };
|
|
747
|
+
}
|
|
596
748
|
await this.#operationChain;
|
|
597
749
|
const store = await this.#getStore();
|
|
598
750
|
const doc = await store.get(safe.normalized);
|
|
@@ -628,7 +780,11 @@ export class QmdMemoryManager {
|
|
|
628
780
|
custom: {
|
|
629
781
|
corpora: [...corpora].map(([name, sources]) => sources[0]?.kind === "sessions"
|
|
630
782
|
? { name, kind: "sessions", chatTypes: sources[0].chatTypes }
|
|
631
|
-
: {
|
|
783
|
+
: {
|
|
784
|
+
name,
|
|
785
|
+
kind: sources[0]?.kind === "skills" ? "skills" : "files",
|
|
786
|
+
paths: sources.map((source) => source.configuredPath),
|
|
787
|
+
}),
|
|
632
788
|
...(this.#watchError ? { watchError: this.#watchError } : {}),
|
|
633
789
|
},
|
|
634
790
|
};
|
package/dist/src/plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
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"] });
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/runtime.js
CHANGED
|
@@ -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,111 @@
|
|
|
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[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)
|
|
69
|
+
return;
|
|
70
|
+
const history = state.skills.get(selected.path) ?? {};
|
|
71
|
+
history.suggested = state.turn;
|
|
72
|
+
state.skills.set(selected.path, history);
|
|
73
|
+
return {
|
|
74
|
+
prependContext: `A potentially relevant skill is available: ${JSON.stringify(selected.name)} ` +
|
|
75
|
+
`at ${JSON.stringify(selected.path)}. Check it before proceeding if applicable.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
api.logger.warn(`unblock-memory skill whisperer search failed: ${String(error)}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
api.on("after_tool_call", (event, context) => {
|
|
84
|
+
if (event.toolName !== "read" || event.error ||
|
|
85
|
+
(isRecord(event.result) && event.result.isError === true) || !context.agentId)
|
|
86
|
+
return;
|
|
87
|
+
const scope = sessionScope(context);
|
|
88
|
+
const path = scope ? readPath(event.params) : undefined;
|
|
89
|
+
if (!scope || !path)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
const canonicalPath = runtime.resolveSkillPath(active(context.agentId), path);
|
|
93
|
+
if (!canonicalPath)
|
|
94
|
+
return;
|
|
95
|
+
const state = stateFor(scope);
|
|
96
|
+
const history = state.skills.get(canonicalPath) ?? {};
|
|
97
|
+
history.opened = state.turn;
|
|
98
|
+
state.skills.set(canonicalPath, history);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
api.logger.warn(`unblock-memory skill whisperer read tracking failed: ${String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}, { matcher: ["read"] });
|
|
104
|
+
api.on("session_end", (event, context) => {
|
|
105
|
+
sessions.delete(event.sessionId);
|
|
106
|
+
if (event.sessionKey)
|
|
107
|
+
sessions.delete(event.sessionKey);
|
|
108
|
+
if (context.sessionKey)
|
|
109
|
+
sessions.delete(context.sessionKey);
|
|
110
|
+
});
|
|
111
|
+
}
|
package/dist/src/sources.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/sources.js
CHANGED
|
@@ -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 =
|
|
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)
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "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 },
|
|
@@ -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.5 },
|
|
117
|
+
"cooldownTurns": { "type": "integer", "minimum": 0, "default": 10 }
|
|
118
|
+
},
|
|
119
|
+
"default": {
|
|
120
|
+
"enabled": false,
|
|
121
|
+
"historyMessages": 5,
|
|
122
|
+
"minScore": 0.5,
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
78
|
-
|
|
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,
|