@unblocklabs/unblock-memory 0.1.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.
@@ -0,0 +1,325 @@
1
+ import { Type } from "typebox";
2
+ import { jsonResult, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
3
+ import { resolveConfig } from "./config.js";
4
+ import { QmdMemoryRuntime } from "./runtime.js";
5
+ function getContext(ctx) {
6
+ const cfg = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
7
+ if (!cfg || !ctx.agentId)
8
+ return undefined;
9
+ return { cfg, agentId: ctx.agentId };
10
+ }
11
+ function toolParams(value) {
12
+ return value !== null && typeof value === "object" && !Array.isArray(value)
13
+ ? Object.fromEntries(Object.entries(value))
14
+ : {};
15
+ }
16
+ function createSearchTool(runtime, ctx) {
17
+ const active = getContext(ctx);
18
+ if (!active)
19
+ return null;
20
+ return {
21
+ name: "memory_search",
22
+ label: "Memory Search",
23
+ description: "Search canonical Markdown memory with semantic vector retrieval.",
24
+ parameters: Type.Object({
25
+ query: Type.String(),
26
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
27
+ minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
28
+ }),
29
+ async execute(_toolCallId, params, signal) {
30
+ const raw = toolParams(params);
31
+ const query = readStringParam(raw, "query");
32
+ if (!query)
33
+ throw new Error("query is required");
34
+ const maxResults = typeof raw.maxResults === "number" ? raw.maxResults : undefined;
35
+ const minScore = typeof raw.minScore === "number" ? raw.minScore : undefined;
36
+ const { manager, error } = await runtime.getMemorySearchManager(active);
37
+ if (!manager)
38
+ return jsonResult({ results: [], error: error ?? "memory unavailable" });
39
+ const results = await manager.search(query, { maxResults, minScore, signal });
40
+ return jsonResult({ results, provider: "unblock-memory" });
41
+ },
42
+ };
43
+ }
44
+ function createGetTool(runtime, ctx) {
45
+ const active = getContext(ctx);
46
+ if (!active)
47
+ return null;
48
+ return {
49
+ name: "memory_get",
50
+ label: "Memory Get",
51
+ description: "Read an exact qmd:// path returned by memory_search.",
52
+ parameters: Type.Object({
53
+ path: Type.String(),
54
+ from: Type.Optional(Type.Integer({ minimum: 1 })),
55
+ lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
56
+ }),
57
+ async execute(_toolCallId, params) {
58
+ const raw = toolParams(params);
59
+ const path = readStringParam(raw, "path");
60
+ if (!path)
61
+ throw new Error("path is required");
62
+ const { manager, error } = await runtime.getMemorySearchManager(active);
63
+ if (!manager)
64
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
65
+ return jsonResult(await manager.readFile({
66
+ relPath: path,
67
+ from: typeof raw.from === "number" ? raw.from : undefined,
68
+ lines: typeof raw.lines === "number" ? raw.lines : undefined,
69
+ }));
70
+ },
71
+ };
72
+ }
73
+ const reclusterParameters = Type.Object({
74
+ space: Type.Optional(Type.Object({
75
+ method: Type.Optional(Type.Union([Type.Literal("umap"), Type.Literal("none")])),
76
+ nComponents: Type.Optional(Type.Integer({ minimum: 2, maximum: 100 })),
77
+ nNeighbors: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
78
+ minDist: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
79
+ }, { additionalProperties: false })),
80
+ hdbscan: Type.Optional(Type.Object({
81
+ minClusterSize: Type.Optional(Type.Integer({ minimum: 2, maximum: 100_000 })),
82
+ minSamples: Type.Optional(Type.Integer({ minimum: 1, maximum: 100_000 })),
83
+ clusterSelectionMethod: Type.Optional(Type.Union([Type.Literal("eom"), Type.Literal("leaf")])),
84
+ clusterSelectionEpsilon: Type.Optional(Type.Number({ minimum: 0 })),
85
+ allowSingleCluster: Type.Optional(Type.Boolean()),
86
+ }, { additionalProperties: false })),
87
+ seed: Type.Optional(Type.Integer({ minimum: 0, maximum: 4_294_967_295 })),
88
+ }, { additionalProperties: false });
89
+ function requireObject(value, name) {
90
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
91
+ throw new Error(`${name} must be an object`);
92
+ }
93
+ return Object.fromEntries(Object.entries(value));
94
+ }
95
+ function optionalNumber(value, key, constraints) {
96
+ const candidate = value[key];
97
+ if (candidate === undefined)
98
+ return undefined;
99
+ const valid = typeof candidate === "number" && Number.isFinite(candidate) &&
100
+ candidate >= constraints.minimum &&
101
+ (constraints.maximum === undefined || candidate <= constraints.maximum) &&
102
+ (!constraints.integer || Number.isInteger(candidate));
103
+ if (!valid)
104
+ throw new Error(`${key} is invalid`);
105
+ return candidate;
106
+ }
107
+ function requireOnlyKeys(value, allowed) {
108
+ const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
109
+ if (unexpected)
110
+ throw new Error(`${unexpected} is not allowed`);
111
+ }
112
+ function parseReclusterOptions(params) {
113
+ const raw = toolParams(params);
114
+ requireOnlyKeys(raw, ["space", "hdbscan", "seed"]);
115
+ const options = {};
116
+ if (raw.space !== undefined) {
117
+ const space = requireObject(raw.space, "space");
118
+ requireOnlyKeys(space, ["method", "nComponents", "nNeighbors", "minDist"]);
119
+ const method = space.method;
120
+ if (method !== undefined && method !== "umap" && method !== "none") {
121
+ throw new Error("space.method is invalid");
122
+ }
123
+ options.space = {
124
+ ...(method === undefined ? {} : { method }),
125
+ ...optionalEntry("nComponents", optionalNumber(space, "nComponents", { minimum: 2, maximum: 100, integer: true })),
126
+ ...optionalEntry("nNeighbors", optionalNumber(space, "nNeighbors", { minimum: 2, maximum: 200, integer: true })),
127
+ ...optionalEntry("minDist", optionalNumber(space, "minDist", { minimum: 0, maximum: 1 })),
128
+ };
129
+ }
130
+ if (raw.hdbscan !== undefined) {
131
+ const hdbscan = requireObject(raw.hdbscan, "hdbscan");
132
+ requireOnlyKeys(hdbscan, [
133
+ "minClusterSize",
134
+ "minSamples",
135
+ "clusterSelectionMethod",
136
+ "clusterSelectionEpsilon",
137
+ "allowSingleCluster",
138
+ ]);
139
+ const method = hdbscan.clusterSelectionMethod;
140
+ if (method !== undefined && method !== "eom" && method !== "leaf") {
141
+ throw new Error("hdbscan.clusterSelectionMethod is invalid");
142
+ }
143
+ const allowSingleCluster = hdbscan.allowSingleCluster;
144
+ if (allowSingleCluster !== undefined && typeof allowSingleCluster !== "boolean") {
145
+ throw new Error("hdbscan.allowSingleCluster is invalid");
146
+ }
147
+ options.hdbscan = {
148
+ ...optionalEntry("minClusterSize", optionalNumber(hdbscan, "minClusterSize", { minimum: 2, maximum: 100_000, integer: true })),
149
+ ...optionalEntry("minSamples", optionalNumber(hdbscan, "minSamples", { minimum: 1, maximum: 100_000, integer: true })),
150
+ ...(method === undefined ? {} : { clusterSelectionMethod: method }),
151
+ ...optionalEntry("clusterSelectionEpsilon", optionalNumber(hdbscan, "clusterSelectionEpsilon", { minimum: 0 })),
152
+ ...(allowSingleCluster === undefined ? {} : { allowSingleCluster }),
153
+ };
154
+ }
155
+ const seed = optionalNumber(raw, "seed", { minimum: 0, maximum: 4_294_967_295, integer: true });
156
+ if (seed !== undefined)
157
+ options.seed = seed;
158
+ return options;
159
+ }
160
+ function optionalEntry(key, value) {
161
+ return value === undefined ? {} : { [key]: value };
162
+ }
163
+ function createReclusterTool(runtime, ctx) {
164
+ const active = getContext(ctx);
165
+ if (!active)
166
+ return null;
167
+ return {
168
+ name: "memory_recluster",
169
+ label: "Recluster Memory",
170
+ description: "Rebuild memory clusters from existing QMD vectors. Call only when memory_list_clusters reports missing or stale analysis.",
171
+ parameters: reclusterParameters,
172
+ async execute(_toolCallId, params, signal) {
173
+ const options = parseReclusterOptions(params);
174
+ const { manager, error } = await runtime.getMemorySearchManager(active);
175
+ if (!manager)
176
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
177
+ try {
178
+ return jsonResult(await manager.recluster(options, signal));
179
+ }
180
+ catch (analysisError) {
181
+ return jsonResult({
182
+ status: "unavailable",
183
+ error: analysisError instanceof Error ? analysisError.message : String(analysisError),
184
+ });
185
+ }
186
+ },
187
+ };
188
+ }
189
+ function createListClustersTool(runtime, ctx) {
190
+ const active = getContext(ctx);
191
+ if (!active)
192
+ return null;
193
+ return {
194
+ name: "memory_list_clusters",
195
+ label: "List Memory Clusters",
196
+ description: "List current memory clusters and freshness. Call this before memory_recluster or memory_fetch_cluster.",
197
+ parameters: Type.Object({
198
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
199
+ }, { additionalProperties: false }),
200
+ async execute(_toolCallId, params) {
201
+ const raw = toolParams(params);
202
+ requireOnlyKeys(raw, ["limit"]);
203
+ const limit = optionalNumber(raw, "limit", { minimum: 1, maximum: 50, integer: true });
204
+ const { manager, error } = await runtime.getMemorySearchManager(active);
205
+ if (!manager)
206
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
207
+ return jsonResult(await manager.listClusters(limit));
208
+ },
209
+ };
210
+ }
211
+ function createFetchClusterTool(runtime, ctx) {
212
+ const active = getContext(ctx);
213
+ if (!active)
214
+ return null;
215
+ return {
216
+ name: "memory_fetch_cluster",
217
+ label: "Fetch Memory Cluster",
218
+ description: "Fetch the top representative QMD chunks for a clusterId returned by memory_list_clusters.",
219
+ parameters: Type.Object({
220
+ clusterId: Type.String({ pattern: "^[0-9a-f]{10}$" }),
221
+ topK: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
222
+ }, { additionalProperties: false }),
223
+ async execute(_toolCallId, params) {
224
+ const raw = toolParams(params);
225
+ requireOnlyKeys(raw, ["clusterId", "topK"]);
226
+ const clusterId = readStringParam(raw, "clusterId");
227
+ if (!clusterId || !/^[0-9a-f]{10}$/.test(clusterId))
228
+ throw new Error("clusterId is invalid");
229
+ const topK = optionalNumber(raw, "topK", { minimum: 1, maximum: 50, integer: true });
230
+ const { manager, error } = await runtime.getMemorySearchManager(active);
231
+ if (!manager)
232
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
233
+ return jsonResult(await manager.fetchCluster({ clusterId, topK }));
234
+ },
235
+ };
236
+ }
237
+ function formatDateInTimezone(timestamp, timezone) {
238
+ const parts = new Intl.DateTimeFormat("en-US", {
239
+ timeZone: timezone,
240
+ year: "numeric",
241
+ month: "2-digit",
242
+ day: "2-digit",
243
+ }).formatToParts(new Date(timestamp));
244
+ const part = (type) => parts.find((entry) => entry.type === type)?.value;
245
+ return `${part("year")}-${part("month")}-${part("day")}`;
246
+ }
247
+ function nonNegativeInteger(value, fallback) {
248
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
249
+ ? Math.floor(value)
250
+ : fallback;
251
+ }
252
+ function parseByteSize(value) {
253
+ if (typeof value === "number") {
254
+ const bytes = Math.floor(value);
255
+ return Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : undefined;
256
+ }
257
+ if (typeof value !== "string")
258
+ return undefined;
259
+ const match = /^(\d+(?:\.\d+)?)(b|k|kb|m|mb|g|gb|t|tb)?$/i.exec(value.trim());
260
+ if (!match)
261
+ return undefined;
262
+ const unit = (match[2] ?? "b").toLowerCase();
263
+ const powers = {
264
+ b: 0,
265
+ k: 1,
266
+ kb: 1,
267
+ m: 2,
268
+ mb: 2,
269
+ g: 3,
270
+ gb: 3,
271
+ t: 4,
272
+ tb: 4,
273
+ };
274
+ const bytes = Math.round(Number(match[1]) * 1024 ** powers[unit]);
275
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
276
+ }
277
+ function resolveTimezone(cfg) {
278
+ const configured = cfg?.agents?.defaults?.userTimezone?.trim();
279
+ if (configured) {
280
+ try {
281
+ new Intl.DateTimeFormat("en-US", { timeZone: configured }).format();
282
+ return configured;
283
+ }
284
+ catch {
285
+ // Host validation normally prevents this; fall through defensively.
286
+ }
287
+ }
288
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
289
+ }
290
+ export function resolveFlushPlan(params = {}) {
291
+ const configured = params.cfg?.agents?.defaults?.compaction?.memoryFlush;
292
+ if (configured?.enabled === false)
293
+ return null;
294
+ const nowMs = params.nowMs ?? Date.now();
295
+ const date = formatDateInTimezone(nowMs, resolveTimezone(params.cfg));
296
+ const target = `memory/${date}.md`;
297
+ return {
298
+ softThresholdTokens: nonNegativeInteger(configured?.softThresholdTokens, 4000),
299
+ forceFlushTranscriptBytes: parseByteSize(configured?.forceFlushTranscriptBytes) ?? 2 * 1024 * 1024,
300
+ reserveTokensFloor: 20_000,
301
+ model: configured?.model?.trim() || undefined,
302
+ prompt: `Pre-compaction memory flush. Store durable memories only in ${target}. If it exists, append; never overwrite it or bootstrap files. Do not create timestamped variants. If nothing is durable, reply NO_REPLY.`,
303
+ systemPrompt: `Capture durable memories in ${target}; append only and do not overwrite bootstrap files. Usually NO_REPLY is correct.`,
304
+ relativePath: target,
305
+ };
306
+ }
307
+ export function registerUnblockMemory(api) {
308
+ const config = resolveConfig(api.pluginConfig);
309
+ const runtime = new QmdMemoryRuntime(config.paths, config.analysis.executable);
310
+ const capability = {
311
+ deterministicRecallToolName: "memory_search",
312
+ supportsPrivateTranscriptRecall: false,
313
+ promptBuilder: ({ availableTools }) => availableTools.has("memory_search")
314
+ ? ["Use memory_search for relevant past facts, then memory_get when more surrounding context is needed."]
315
+ : [],
316
+ flushPlanResolver: resolveFlushPlan,
317
+ runtime,
318
+ };
319
+ api.registerMemoryCapability(capability);
320
+ api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
321
+ api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
322
+ api.registerTool((ctx) => createReclusterTool(runtime, ctx), { names: ["memory_recluster"] });
323
+ api.registerTool((ctx) => createListClustersTool(runtime, ctx), { names: ["memory_list_clusters"] });
324
+ api.registerTool((ctx) => createFetchClusterTool(runtime, ctx), { names: ["memory_fetch_cluster"] });
325
+ }
@@ -0,0 +1,24 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { MemoryPluginRuntimeContract } from "./contracts.js";
3
+ import { QmdMemoryManager } from "./manager.js";
4
+ export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
5
+ #private;
6
+ constructor(paths: readonly string[], analysisExecutable?: string);
7
+ getMemorySearchManager(params: {
8
+ cfg: OpenClawConfig;
9
+ agentId: string;
10
+ }): Promise<{
11
+ manager: QmdMemoryManager;
12
+ error?: undefined;
13
+ } | {
14
+ manager: null;
15
+ error: string;
16
+ }>;
17
+ resolveMemoryBackendConfig(): {
18
+ backend: "builtin";
19
+ };
20
+ closeMemorySearchManager(params: {
21
+ agentId: string;
22
+ }): Promise<void>;
23
+ closeAllMemorySearchManagers(): Promise<void>;
24
+ }
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+ import { resolveAgentWorkspaceDir, resolveStateDir, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
3
+ import { QmdMemoryManager } from "./manager.js";
4
+ import { resolveSource } from "./sources.js";
5
+ export class QmdMemoryRuntime {
6
+ #paths;
7
+ #analysisExecutable;
8
+ #managers = new Map();
9
+ constructor(paths, analysisExecutable) {
10
+ this.#paths = paths;
11
+ this.#analysisExecutable = analysisExecutable;
12
+ }
13
+ async getMemorySearchManager(params) {
14
+ let pending = this.#managers.get(params.agentId);
15
+ if (!pending) {
16
+ pending = this.#createManager(params.cfg, params.agentId);
17
+ this.#managers.set(params.agentId, pending);
18
+ }
19
+ try {
20
+ return { manager: await pending };
21
+ }
22
+ catch (error) {
23
+ this.#managers.delete(params.agentId);
24
+ return { manager: null, error: error instanceof Error ? error.message : String(error) };
25
+ }
26
+ }
27
+ resolveMemoryBackendConfig() {
28
+ return { backend: "builtin" };
29
+ }
30
+ async closeMemorySearchManager(params) {
31
+ const pending = this.#managers.get(params.agentId);
32
+ this.#managers.delete(params.agentId);
33
+ await (await pending)?.close();
34
+ }
35
+ async closeAllMemorySearchManagers() {
36
+ const managers = [...this.#managers.values()];
37
+ this.#managers.clear();
38
+ await Promise.all(managers.map(async (pending) => (await pending).close()));
39
+ }
40
+ async #createManager(cfg, agentId) {
41
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
42
+ const manager = new QmdMemoryManager({
43
+ workspaceDir,
44
+ dbPath: join(resolveStateDir(), "agents", agentId, "unblock-memory", "index.sqlite"),
45
+ sources: this.#paths.map((source) => resolveSource(workspaceDir, source)),
46
+ analysisExecutable: this.#analysisExecutable,
47
+ });
48
+ await manager.start();
49
+ return manager;
50
+ }
51
+ }
@@ -0,0 +1,13 @@
1
+ export type ResolvedSource = {
2
+ collection: string;
3
+ configuredPath: string;
4
+ root: string;
5
+ pattern: string;
6
+ watchPath: string;
7
+ };
8
+ export declare function resolveSource(workspaceDir: string, configuredPath: string): ResolvedSource;
9
+ export declare function parseSafeVirtualPath(virtualPath: string, sources: ReadonlyMap<string, ResolvedSource>): {
10
+ source: ResolvedSource;
11
+ relativePath: string;
12
+ normalized: string;
13
+ } | undefined;
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import picomatch from "picomatch";
6
+ const GLOB_MAGIC = /[*?[{(]/;
7
+ function expandHome(value) {
8
+ return value === "~" ? homedir() : value.startsWith("~/") ? resolve(homedir(), value.slice(2)) : value;
9
+ }
10
+ function collectionName(source) {
11
+ return `source-${createHash("sha256").update(source).digest("hex").slice(0, 12)}`;
12
+ }
13
+ function assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root) {
14
+ if (isAbsolute(expandHome(configuredPath)))
15
+ return;
16
+ const workspace = resolve(workspaceDir);
17
+ const rootRelative = relative(workspace, root);
18
+ if (rootRelative === ".." || rootRelative.startsWith(`..${sep}`) || isAbsolute(rootRelative)) {
19
+ return;
20
+ }
21
+ let current = root;
22
+ while (current !== workspace) {
23
+ if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
24
+ throw new Error(`unblock-memory source path must not traverse a workspace symlink: ${configuredPath}`);
25
+ }
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ break;
29
+ current = parent;
30
+ }
31
+ }
32
+ export function resolveSource(workspaceDir, configuredPath) {
33
+ const expanded = expandHome(configuredPath);
34
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(workspaceDir, expanded);
35
+ if (existsSync(absolute)) {
36
+ const stat = statSync(absolute);
37
+ const root = stat.isDirectory() ? absolute : dirname(absolute);
38
+ const pattern = stat.isDirectory() ? "**/*.md" : basename(absolute);
39
+ assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
40
+ return {
41
+ collection: collectionName(absolute),
42
+ configuredPath,
43
+ root,
44
+ pattern,
45
+ watchPath: absolute,
46
+ };
47
+ }
48
+ const magicIndex = absolute.search(GLOB_MAGIC);
49
+ if (magicIndex < 0) {
50
+ const isExactMarkdownFile = basename(absolute).toLowerCase().endsWith(".md");
51
+ const root = isExactMarkdownFile ? dirname(absolute) : absolute;
52
+ assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
53
+ return {
54
+ collection: collectionName(absolute),
55
+ configuredPath,
56
+ root,
57
+ pattern: isExactMarkdownFile ? basename(absolute) : "**/*.md",
58
+ watchPath: absolute,
59
+ };
60
+ }
61
+ const prefix = absolute.slice(0, magicIndex);
62
+ const root = prefix.slice(0, prefix.lastIndexOf(sep)) || sep;
63
+ const pattern = relative(root, absolute).split(sep).join("/");
64
+ assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
65
+ return { collection: collectionName(absolute), configuredPath, root, pattern, watchPath: root };
66
+ }
67
+ export function parseSafeVirtualPath(virtualPath, sources) {
68
+ const match = /^qmd:\/\/([^/]+)\/(.+)$/.exec(virtualPath.trim());
69
+ if (!match)
70
+ return undefined;
71
+ const source = sources.get(match[1]);
72
+ const relativePath = match[2];
73
+ if (!source || relativePath.includes("\0") || isAbsolute(relativePath))
74
+ return undefined;
75
+ const portableRelativePath = relativePath.split(sep).join("/");
76
+ if (!picomatch.isMatch(portableRelativePath, source.pattern, { dot: true }))
77
+ return undefined;
78
+ const target = resolve(source.root, relativePath);
79
+ if (relative(source.root, target).startsWith(`..${sep}`) || relative(source.root, target) === "..") {
80
+ return undefined;
81
+ }
82
+ if (!target.toLowerCase().endsWith(".md"))
83
+ return undefined;
84
+ try {
85
+ const realRoot = realpathSync(source.root);
86
+ const realTarget = realpathSync(target);
87
+ const rel = relative(realRoot, realTarget);
88
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
89
+ return undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ return { source, relativePath, normalized: `qmd://${source.collection}/${relativePath}` };
95
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "id": "unblock-memory",
3
+ "name": "Unblock Memory",
4
+ "version": "0.1.1",
5
+ "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
+ "kind": "memory",
7
+ "activation": { "onStartup": false },
8
+ "contracts": { "tools": ["memory_search", "memory_get", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
9
+ "toolMetadata": {
10
+ "memory_recluster": { "sideEffecting": true },
11
+ "memory_list_clusters": { "replaySafe": true },
12
+ "memory_fetch_cluster": { "replaySafe": true }
13
+ },
14
+ "uiHints": {
15
+ "paths": {
16
+ "label": "Memory Paths",
17
+ "help": "Exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace."
18
+ },
19
+ "analysis.executable": {
20
+ "label": "Memory Analysis Worker",
21
+ "help": "Optional absolute path to the locally installed unblock-memory-analysis executable."
22
+ }
23
+ },
24
+ "configSchema": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "paths": {
29
+ "type": "array",
30
+ "items": { "type": "string", "minLength": 1 },
31
+ "default": ["MEMORY.md", "USER.md", "memory/**/*.md"]
32
+ },
33
+ "analysis": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "executable": { "type": "string", "minLength": 1 }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@unblocklabs/unblock-memory",
3
+ "version": "0.1.1",
4
+ "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/unblocklabs-ai/unblock-memory.git"
12
+ },
13
+ "homepage": "https://github.com/unblocklabs-ai/unblock-memory#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/unblocklabs-ai/unblock-memory/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "provenance": true
20
+ },
21
+ "files": ["dist", "README.md", "openclaw.plugin.json"],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test": "node --import tsx --test --test-concurrency=1 tests/**/*.test.ts",
26
+ "plugin:inspect": "plugin-inspector check --config plugin-inspector.config.json --no-openclaw",
27
+ "plugin:inspect:runtime": "plugin-inspector check --config plugin-inspector.config.json --no-openclaw --runtime --mock-sdk --allow-execute",
28
+ "release:check": "node scripts/check-release-version.mjs",
29
+ "preflight": "npm run build && npm run typecheck && npm test && npm run plugin:inspect && npm run plugin:inspect:runtime && npm pack --dry-run"
30
+ },
31
+ "dependencies": {
32
+ "@unblocklabs/qmd": "github:unblocklabs-ai/qmd#0a6b15d",
33
+ "chokidar": "5.0.0",
34
+ "picomatch": "^4.0.5",
35
+ "typebox": "1.3.6"
36
+ },
37
+ "devDependencies": {
38
+ "@openclaw/plugin-inspector": "^0.3.10",
39
+ "@types/node": "^24.6.0",
40
+ "@types/picomatch": "^4.0.2",
41
+ "openclaw": "2026.8.1-beta.3",
42
+ "tsx": "^4.20.6",
43
+ "typescript": "^5.9.3"
44
+ },
45
+ "peerDependencies": {
46
+ "openclaw": ">=2026.8.1-beta.3"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "openclaw": { "optional": true }
50
+ },
51
+ "engines": { "node": ">=22.0.0" },
52
+ "openclaw": {
53
+ "extensions": ["./dist/index.js"],
54
+ "compat": { "pluginApi": ">=2026.8.1-beta.3", "minGatewayVersion": "2026.8.1-beta.3" },
55
+ "build": { "openclawVersion": "2026.8.1-beta.3", "pluginSdkVersion": "2026.8.1-beta.3" },
56
+ "install": {
57
+ "npmSpec": "@unblocklabs/unblock-memory",
58
+ "defaultChoice": "npm",
59
+ "minHostVersion": ">=2026.8.1-beta.3"
60
+ },
61
+ "release": { "publishToClawHub": false, "publishToNpm": true }
62
+ }
63
+ }