@unblocklabs/unblock-memory 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Workspace-native memory for OpenClaw, powered internally by `@unblocklabs/qmd`.
4
4
  It keeps one warm QMD store per agent and exposes the standard `memory_search`
5
- and `memory_get` tools. Search uses semantic chunking v2 and QMD vsearch without
6
- a reranker.
5
+ and `memory_get` tools. Search uses semantic chunking v2 and direct QMD vector
6
+ search without query expansion or a reranker, so only the embedding model loads.
7
7
 
8
8
  Optional memory analysis uses those same stored vectors in the same SQLite
9
9
  index. It does not re-embed memory, copy vectors, or create another database.
@@ -39,6 +39,8 @@ directories, or globs into named corpora:
39
39
  entries: {
40
40
  "unblock-memory": {
41
41
  config: {
42
+ // Default: avoid repeated model cold starts after idle periods.
43
+ keepEmbeddingModelWarm: true,
42
44
  corpora: [
43
45
  {
44
46
  name: "memory",
@@ -73,6 +75,10 @@ omitted, the plugin creates a `memory` corpus containing `MEMORY.md`, `USER.md`,
73
75
  and `memory/**/*.md`. Explicit configuration must include exactly one `memory`
74
76
  corpus; other unique names may be added for custom material.
75
77
 
78
+ `keepEmbeddingModelWarm` defaults to `true`, keeping the embedding model and
79
+ context resident after first use. Set it to `false` to restore QMD's five-minute
80
+ idle unload behavior.
81
+
76
82
  `memory_search` searches every configured corpus by default. Pass
77
83
  `corpora: ["projects"]` to search selected corpora or `corpora: ["all"]` to
78
84
  request all of them explicitly. Search results include their corpus name and
@@ -107,9 +113,10 @@ channel and group conversations; add `direct` explicitly to include DMs. Run
107
113
  check its progress or result. Projections are private derived Markdown
108
114
  under the agent's `unblock-memory/sessions` state directory and can be rebuilt
109
115
  from OpenClaw at any time. Session results include provider, chat type,
110
- conversation identity, and start time. They participate in the same search and
111
- clustering index as file memory. This phase does not sync sessions at startup or
112
- on a schedule; refreshes are manual through `memory_sync_sessions`.
116
+ conversation identity, and start time as an ISO 8601 timestamp. They participate
117
+ in the same search and clustering index as file memory. This phase does not sync
118
+ sessions at startup or on a schedule; refreshes are manual through
119
+ `memory_sync_sessions`.
113
120
 
114
121
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
115
122
  equivalent configured OpenClaw state directory). The first lookup builds the
@@ -14,6 +14,7 @@ export type CorpusConfig = FileCorpusConfig | SessionCorpusConfig;
14
14
  export declare const DEFAULT_CORPORA: readonly FileCorpusConfig[];
15
15
  export type UnblockMemoryConfig = {
16
16
  corpora: readonly CorpusConfig[];
17
+ keepEmbeddingModelWarm: boolean;
17
18
  analysis: {
18
19
  executable?: string;
19
20
  };
@@ -65,16 +65,20 @@ function resolveCorpora(value) {
65
65
  }
66
66
  export function resolveConfig(value) {
67
67
  if (value === undefined || value === null) {
68
- return { corpora: DEFAULT_CORPORA, analysis: {} };
68
+ return { corpora: DEFAULT_CORPORA, keepEmbeddingModelWarm: true, analysis: {} };
69
69
  }
70
70
  if (typeof value !== "object" || Array.isArray(value)) {
71
71
  throw new Error("unblock-memory config must be an object");
72
72
  }
73
73
  const config = value;
74
- assertOnlyKeys(config, ["corpora", "analysis"], "config");
74
+ assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis"], "config");
75
75
  const corpora = resolveCorpora(config.corpora);
76
+ if (config.keepEmbeddingModelWarm !== undefined && typeof config.keepEmbeddingModelWarm !== "boolean") {
77
+ throw new Error("unblock-memory keepEmbeddingModelWarm must be a boolean");
78
+ }
79
+ const keepEmbeddingModelWarm = config.keepEmbeddingModelWarm ?? true;
76
80
  if (config.analysis === undefined)
77
- return { corpora, analysis: {} };
81
+ return { corpora, keepEmbeddingModelWarm, analysis: {} };
78
82
  if (!config.analysis || typeof config.analysis !== "object" || Array.isArray(config.analysis)) {
79
83
  throw new Error("unblock-memory analysis must be an object");
80
84
  }
@@ -82,9 +86,9 @@ export function resolveConfig(value) {
82
86
  assertOnlyKeys(analysis, ["executable"], "analysis");
83
87
  const configured = analysis.executable;
84
88
  if (configured === undefined)
85
- return { corpora, analysis: {} };
89
+ return { corpora, keepEmbeddingModelWarm, analysis: {} };
86
90
  if (typeof configured !== "string" || !configured.trim() || !isAbsolute(configured.trim())) {
87
91
  throw new Error("unblock-memory analysis.executable must be an absolute non-empty path");
88
92
  }
89
- return { corpora, analysis: { executable: configured.trim() } };
93
+ return { corpora, keepEmbeddingModelWarm, analysis: { executable: configured.trim() } };
90
94
  }
@@ -31,6 +31,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
31
31
  workspaceDir: string;
32
32
  sources: readonly ResolvedSource[];
33
33
  storeFactory?: () => Promise<ManagerStore>;
34
+ keepModelsWarm?: boolean;
34
35
  analysisExecutable?: string;
35
36
  analysisRunner?: AnalysisRunner;
36
37
  sessions?: ManagerSessionConfig;
@@ -137,6 +137,7 @@ export class QmdMemoryManager {
137
137
  #workspaceDir;
138
138
  #sources;
139
139
  #storeFactory;
140
+ #keepModelsWarm;
140
141
  #analysisExecutable;
141
142
  #analysisRunner;
142
143
  #sessions;
@@ -157,6 +158,7 @@ export class QmdMemoryManager {
157
158
  this.#workspaceDir = params.workspaceDir;
158
159
  this.#sources = new Map(params.sources.map((source) => [source.collection, source]));
159
160
  this.#storeFactory = params.storeFactory;
161
+ this.#keepModelsWarm = params.keepModelsWarm ?? true;
160
162
  this.#analysisExecutable = params.analysisExecutable;
161
163
  this.#analysisRunner = params.analysisRunner ?? runAnalysisWorker;
162
164
  this.#sessions = params.sessions;
@@ -240,6 +242,7 @@ export class QmdMemoryManager {
240
242
  const { createStore } = await qmdModule;
241
243
  const store = await createStore({
242
244
  dbPath: this.#dbPath,
245
+ keepModelsWarm: this.#keepModelsWarm,
243
246
  config: {
244
247
  collections: Object.fromEntries([...this.#sources.values()].map((source) => [
245
248
  source.collection,
@@ -436,6 +439,7 @@ export class QmdMemoryManager {
436
439
  limit: opts?.maxResults ?? 5,
437
440
  minScore: opts?.minScore ?? 0.3,
438
441
  allowedPaths,
442
+ expand: false,
439
443
  });
440
444
  return hits.flatMap((hit) => {
441
445
  const collection = /^qmd:\/\/([^/]+)\//.exec(hit.file)?.[1];
@@ -58,7 +58,18 @@ function createSearchTool(runtime, ctx) {
58
58
  minScore,
59
59
  signal,
60
60
  });
61
- return jsonResult({ results, provider: "unblock-memory" });
61
+ return jsonResult({
62
+ results: results.map((result) => result.session
63
+ ? {
64
+ ...result,
65
+ session: {
66
+ ...result.session,
67
+ startedAt: new Date(result.session.startedAt).toISOString(),
68
+ },
69
+ }
70
+ : result),
71
+ provider: "unblock-memory",
72
+ });
62
73
  },
63
74
  };
64
75
  }
@@ -96,7 +107,7 @@ function createSyncSessionsTool(runtime, ctx) {
96
107
  parameters: syncSessionsParameters,
97
108
  async execute(_toolCallId, params) {
98
109
  const { force } = Value.Parse(syncSessionsParameters, params);
99
- return jsonResult(runtime.startSessionSync(active, force));
110
+ return jsonResult(await runtime.startSessionSync(active, force));
100
111
  },
101
112
  };
102
113
  }
@@ -111,7 +122,7 @@ function createSyncStatusTool(runtime, ctx) {
111
122
  parameters: syncStatusParameters,
112
123
  async execute(_toolCallId, params) {
113
124
  Value.Parse(syncStatusParameters, params);
114
- return jsonResult(runtime.sessionSyncStatus(active.agentId));
125
+ return jsonResult(await runtime.sessionSyncStatus(active.agentId));
115
126
  },
116
127
  };
117
128
  }
@@ -272,7 +283,10 @@ export function resolveFlushPlan(params = {}) {
272
283
  }
273
284
  export function registerUnblockMemory(api) {
274
285
  const config = resolveConfig(api.pluginConfig);
275
- const runtime = new QmdMemoryRuntime(config.corpora, config.analysis.executable);
286
+ const runtime = new QmdMemoryRuntime(config.corpora, {
287
+ analysisExecutable: config.analysis.executable,
288
+ keepEmbeddingModelWarm: config.keepEmbeddingModelWarm,
289
+ });
276
290
  const capability = {
277
291
  deterministicRecallToolName: "memory_search",
278
292
  supportsPrivateTranscriptRecall: false,
@@ -29,9 +29,22 @@ export type SessionSyncStartResult = {
29
29
  status: "unavailable";
30
30
  error: string;
31
31
  };
32
+ type StoredSessionSyncStatus = Exclude<SessionSyncStatus, {
33
+ status: "idle";
34
+ }>;
35
+ type StoredRunningSessionSync = Extract<StoredSessionSyncStatus, {
36
+ status: "running";
37
+ }> & {
38
+ pid: number;
39
+ };
40
+ export declare function recoverInterruptedSessionSync(directory: string, statusPath: string, stale: StoredRunningSessionSync): Promise<SessionSyncStatus>;
32
41
  export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
33
42
  #private;
34
- constructor(corpora: readonly CorpusConfig[], analysisExecutable?: string);
43
+ constructor(corpora: readonly CorpusConfig[], options?: {
44
+ analysisExecutable?: string;
45
+ keepEmbeddingModelWarm?: boolean;
46
+ stateRoot?: string;
47
+ });
35
48
  getMemorySearchManager(params: {
36
49
  cfg: OpenClawConfig;
37
50
  agentId: string;
@@ -49,10 +62,11 @@ export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
49
62
  startSessionSync(params: {
50
63
  cfg: OpenClawConfig;
51
64
  agentId: string;
52
- }, force?: boolean): SessionSyncStartResult;
53
- sessionSyncStatus(agentId: string): SessionSyncStatus;
65
+ }, force?: boolean): Promise<SessionSyncStartResult>;
66
+ sessionSyncStatus(agentId: string): Promise<SessionSyncStatus>;
54
67
  closeMemorySearchManager(params: {
55
68
  agentId: string;
56
69
  }): Promise<void>;
57
70
  closeAllMemorySearchManagers(): Promise<void>;
58
71
  }
72
+ export {};
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
1
3
  import { join } from "node:path";
2
4
  import { resolveAgentDir, resolveAgentWorkspaceDir, resolveStateDir, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
3
5
  import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
@@ -5,14 +7,72 @@ import { QmdMemoryManager } from "./manager.js";
5
7
  import { resolveTimezone } from "./session-projector.js";
6
8
  import { resolveSessionSource, resolveSources } from "./sources.js";
7
9
  import { classifyWorkspaceMemoryPaths } from "./workspace-path-classifier.js";
10
+ const activeSessionSyncs = new Map();
11
+ async function readJson(path) {
12
+ try {
13
+ return JSON.parse(await readFile(path, "utf8"));
14
+ }
15
+ catch (error) {
16
+ if (error.code === "ENOENT")
17
+ return undefined;
18
+ throw error;
19
+ }
20
+ }
21
+ async function removeIfPresent(path) {
22
+ try {
23
+ await unlink(path);
24
+ }
25
+ catch (error) {
26
+ if (error.code !== "ENOENT")
27
+ throw error;
28
+ }
29
+ }
30
+ async function atomicWriteJson(path, value) {
31
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
+ try {
33
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
34
+ await rename(temporary, path);
35
+ }
36
+ finally {
37
+ await removeIfPresent(temporary);
38
+ }
39
+ }
40
+ export async function recoverInterruptedSessionSync(directory, statusPath, stale) {
41
+ activeSessionSyncs.set(directory, stale.startedAt);
42
+ try {
43
+ const current = await readJson(statusPath);
44
+ if (!current)
45
+ return { status: "idle" };
46
+ if (current.status !== "running")
47
+ return current;
48
+ if (current.startedAt !== stale.startedAt) {
49
+ return { status: "running", phase: current.phase, startedAt: current.startedAt };
50
+ }
51
+ const failed = {
52
+ status: "failed",
53
+ startedAt: stale.startedAt,
54
+ completedAt: new Date().toISOString(),
55
+ error: "session sync interrupted by Gateway restart",
56
+ };
57
+ await atomicWriteJson(statusPath, failed);
58
+ return failed;
59
+ }
60
+ finally {
61
+ if (activeSessionSyncs.get(directory) === stale.startedAt)
62
+ activeSessionSyncs.delete(directory);
63
+ }
64
+ }
8
65
  export class QmdMemoryRuntime {
9
66
  #corpora;
10
67
  #analysisExecutable;
68
+ #keepEmbeddingModelWarm;
69
+ #stateRoot;
11
70
  #managers = new Map();
12
- #sessionSyncStatuses = new Map();
13
- constructor(corpora, analysisExecutable) {
71
+ constructor(corpora, options = {}) {
14
72
  this.#corpora = corpora;
15
- this.#analysisExecutable = analysisExecutable;
73
+ this.#analysisExecutable = options.analysisExecutable;
74
+ this.#keepEmbeddingModelWarm = options.keepEmbeddingModelWarm ?? true;
75
+ this.#stateRoot = options.stateRoot ?? resolveStateDir();
16
76
  }
17
77
  async getMemorySearchManager(params) {
18
78
  let pending = this.#managers.get(params.agentId);
@@ -32,46 +92,93 @@ export class QmdMemoryRuntime {
32
92
  return { backend: "builtin" };
33
93
  }
34
94
  classifyWorkspaceMemoryPaths = classifyWorkspaceMemoryPaths;
35
- startSessionSync(params, force = false) {
95
+ async startSessionSync(params, force = false) {
36
96
  if (!this.#corpora.some((corpus) => corpus.kind === "sessions")) {
37
97
  return {
38
98
  status: "unavailable",
39
99
  error: 'memory session sync requires a configured "sessions" corpus',
40
100
  };
41
101
  }
42
- const current = this.sessionSyncStatus(params.agentId);
43
- if (current.status === "running") {
44
- return { status: "already_running", startedAt: current.startedAt };
45
- }
102
+ const directory = this.#sessionSyncDirectory(params.agentId);
103
+ const statusPath = join(directory, "session-sync-status.json");
104
+ const running = activeSessionSyncs.get(directory);
105
+ if (running)
106
+ return { status: "already_running", startedAt: running };
46
107
  const startedAt = new Date().toISOString();
47
- this.#sessionSyncStatuses.set(params.agentId, { status: "running", phase: "queued", startedAt });
48
- const run = async () => {
49
- const { manager, error } = await this.getMemorySearchManager(params);
50
- if (!manager)
51
- throw new Error(error ?? "memory unavailable");
52
- return await manager.syncSessions(force, (phase) => {
53
- this.#sessionSyncStatuses.set(params.agentId, { status: "running", phase, startedAt });
54
- });
55
- };
56
- void run().then((result) => {
57
- this.#sessionSyncStatuses.set(params.agentId, {
58
- status: "completed",
59
- startedAt,
60
- completedAt: new Date().toISOString(),
61
- ...result,
62
- });
63
- }, (error) => {
64
- this.#sessionSyncStatuses.set(params.agentId, {
65
- status: "failed",
108
+ activeSessionSyncs.set(directory, startedAt);
109
+ try {
110
+ await mkdir(directory, { recursive: true, mode: 0o700 });
111
+ await atomicWriteJson(statusPath, {
112
+ status: "running",
113
+ phase: "queued",
114
+ pid: process.pid,
66
115
  startedAt,
67
- completedAt: new Date().toISOString(),
68
- error: error instanceof Error ? error.message : String(error),
69
116
  });
70
- });
117
+ }
118
+ catch (error) {
119
+ if (activeSessionSyncs.get(directory) === startedAt)
120
+ activeSessionSyncs.delete(directory);
121
+ throw error;
122
+ }
123
+ void (async () => {
124
+ let statusWrites = Promise.resolve();
125
+ const writePhase = (phase) => {
126
+ statusWrites = statusWrites.then(() => atomicWriteJson(statusPath, {
127
+ status: "running",
128
+ phase,
129
+ pid: process.pid,
130
+ startedAt,
131
+ }));
132
+ };
133
+ try {
134
+ const { manager, error } = await this.getMemorySearchManager(params);
135
+ if (!manager)
136
+ throw new Error(error ?? "memory unavailable");
137
+ const result = await manager.syncSessions(force, writePhase);
138
+ await statusWrites;
139
+ await atomicWriteJson(statusPath, {
140
+ status: "completed",
141
+ startedAt,
142
+ completedAt: new Date().toISOString(),
143
+ ...result,
144
+ });
145
+ }
146
+ catch (error) {
147
+ await statusWrites.catch(() => { });
148
+ try {
149
+ await atomicWriteJson(statusPath, {
150
+ status: "failed",
151
+ startedAt,
152
+ completedAt: new Date().toISOString(),
153
+ error: error instanceof Error ? error.message : String(error),
154
+ });
155
+ }
156
+ catch {
157
+ // The next status read converts the persisted running state to interrupted.
158
+ }
159
+ }
160
+ finally {
161
+ if (activeSessionSyncs.get(directory) === startedAt)
162
+ activeSessionSyncs.delete(directory);
163
+ }
164
+ })().catch(() => { });
71
165
  return { status: "started", startedAt };
72
166
  }
73
- sessionSyncStatus(agentId) {
74
- return this.#sessionSyncStatuses.get(agentId) ?? { status: "idle" };
167
+ async sessionSyncStatus(agentId) {
168
+ const directory = this.#sessionSyncDirectory(agentId);
169
+ const statusPath = join(directory, "session-sync-status.json");
170
+ const status = await readJson(statusPath);
171
+ const running = activeSessionSyncs.get(directory);
172
+ if (running) {
173
+ return status?.status === "running" && status.startedAt === running
174
+ ? { status: "running", phase: status.phase, startedAt: running }
175
+ : { status: "running", phase: "queued", startedAt: running };
176
+ }
177
+ if (!status)
178
+ return { status: "idle" };
179
+ if (status.status !== "running")
180
+ return status;
181
+ return await recoverInterruptedSessionSync(directory, statusPath, status);
75
182
  }
76
183
  async closeMemorySearchManager(params) {
77
184
  const pending = this.#managers.get(params.agentId);
@@ -85,7 +192,7 @@ export class QmdMemoryRuntime {
85
192
  }
86
193
  async #createManager(cfg, agentId) {
87
194
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
88
- const stateDir = join(resolveStateDir(), "agents", agentId, "unblock-memory");
195
+ const stateDir = join(this.#stateRoot, "agents", agentId, "unblock-memory");
89
196
  const fileCorpora = this.#corpora.filter((corpus) => corpus.kind === "files");
90
197
  const sessionCorpus = this.#corpora.find((corpus) => corpus.kind === "sessions");
91
198
  const sources = resolveSources(workspaceDir, fileCorpora);
@@ -98,6 +205,7 @@ export class QmdMemoryRuntime {
98
205
  workspaceDir,
99
206
  dbPath: join(stateDir, "index.sqlite"),
100
207
  sources,
208
+ keepModelsWarm: this.#keepEmbeddingModelWarm,
101
209
  analysisExecutable: this.#analysisExecutable,
102
210
  ...(sessionCorpus && sessionSource ? {
103
211
  sessions: {
@@ -115,4 +223,7 @@ export class QmdMemoryRuntime {
115
223
  await manager.start();
116
224
  return manager;
117
225
  }
226
+ #sessionSyncDirectory(agentId) {
227
+ return join(this.#stateRoot, "agents", agentId, "unblock-memory");
228
+ }
118
229
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.2.2",
4
+ "version": "0.2.4",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
@@ -14,6 +14,10 @@
14
14
  "memory_fetch_cluster": { "replaySafe": true }
15
15
  },
16
16
  "uiHints": {
17
+ "keepEmbeddingModelWarm": {
18
+ "label": "Keep Embedding Model Warm",
19
+ "help": "Keep the QMD embedding model resident after first use. Disable to unload it after five minutes without model activity."
20
+ },
17
21
  "corpora": {
18
22
  "label": "Memory Corpora",
19
23
  "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace."
@@ -27,6 +31,10 @@
27
31
  "type": "object",
28
32
  "additionalProperties": false,
29
33
  "properties": {
34
+ "keepEmbeddingModelWarm": {
35
+ "type": "boolean",
36
+ "default": true
37
+ },
30
38
  "corpora": {
31
39
  "type": "array",
32
40
  "minItems": 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "preflight": "npm run knip && npm run build && npm run typecheck && npm test && npm run plugin:inspect && npm run plugin:inspect:runtime && npm pack --dry-run"
31
31
  },
32
32
  "dependencies": {
33
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.1/unblocklabs-qmd-2.9.1.tgz",
33
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.2/unblocklabs-qmd-2.9.2.tgz",
34
34
  "chokidar": "5.0.0",
35
35
  "picomatch": "^4.0.5",
36
36
  "typebox": "1.3.6"