@unblocklabs/unblock-memory 0.2.2 → 0.2.3

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.
@@ -96,7 +96,7 @@ function createSyncSessionsTool(runtime, ctx) {
96
96
  parameters: syncSessionsParameters,
97
97
  async execute(_toolCallId, params) {
98
98
  const { force } = Value.Parse(syncSessionsParameters, params);
99
- return jsonResult(runtime.startSessionSync(active, force));
99
+ return jsonResult(await runtime.startSessionSync(active, force));
100
100
  },
101
101
  };
102
102
  }
@@ -111,7 +111,7 @@ function createSyncStatusTool(runtime, ctx) {
111
111
  parameters: syncStatusParameters,
112
112
  async execute(_toolCallId, params) {
113
113
  Value.Parse(syncStatusParameters, params);
114
- return jsonResult(runtime.sessionSyncStatus(active.agentId));
114
+ return jsonResult(await runtime.sessionSyncStatus(active.agentId));
115
115
  },
116
116
  };
117
117
  }
@@ -29,9 +29,18 @@ 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[], analysisExecutable?: string, stateRoot?: string);
35
44
  getMemorySearchManager(params: {
36
45
  cfg: OpenClawConfig;
37
46
  agentId: string;
@@ -49,10 +58,11 @@ export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
49
58
  startSessionSync(params: {
50
59
  cfg: OpenClawConfig;
51
60
  agentId: string;
52
- }, force?: boolean): SessionSyncStartResult;
53
- sessionSyncStatus(agentId: string): SessionSyncStatus;
61
+ }, force?: boolean): Promise<SessionSyncStartResult>;
62
+ sessionSyncStatus(agentId: string): Promise<SessionSyncStatus>;
54
63
  closeMemorySearchManager(params: {
55
64
  agentId: string;
56
65
  }): Promise<void>;
57
66
  closeAllMemorySearchManagers(): Promise<void>;
58
67
  }
68
+ 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,70 @@ 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
+ #stateRoot;
11
69
  #managers = new Map();
12
- #sessionSyncStatuses = new Map();
13
- constructor(corpora, analysisExecutable) {
70
+ constructor(corpora, analysisExecutable, stateRoot = resolveStateDir()) {
14
71
  this.#corpora = corpora;
15
72
  this.#analysisExecutable = analysisExecutable;
73
+ this.#stateRoot = stateRoot;
16
74
  }
17
75
  async getMemorySearchManager(params) {
18
76
  let pending = this.#managers.get(params.agentId);
@@ -32,46 +90,93 @@ export class QmdMemoryRuntime {
32
90
  return { backend: "builtin" };
33
91
  }
34
92
  classifyWorkspaceMemoryPaths = classifyWorkspaceMemoryPaths;
35
- startSessionSync(params, force = false) {
93
+ async startSessionSync(params, force = false) {
36
94
  if (!this.#corpora.some((corpus) => corpus.kind === "sessions")) {
37
95
  return {
38
96
  status: "unavailable",
39
97
  error: 'memory session sync requires a configured "sessions" corpus',
40
98
  };
41
99
  }
42
- const current = this.sessionSyncStatus(params.agentId);
43
- if (current.status === "running") {
44
- return { status: "already_running", startedAt: current.startedAt };
45
- }
100
+ const directory = this.#sessionSyncDirectory(params.agentId);
101
+ const statusPath = join(directory, "session-sync-status.json");
102
+ const running = activeSessionSyncs.get(directory);
103
+ if (running)
104
+ return { status: "already_running", startedAt: running };
46
105
  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",
106
+ activeSessionSyncs.set(directory, startedAt);
107
+ try {
108
+ await mkdir(directory, { recursive: true, mode: 0o700 });
109
+ await atomicWriteJson(statusPath, {
110
+ status: "running",
111
+ phase: "queued",
112
+ pid: process.pid,
66
113
  startedAt,
67
- completedAt: new Date().toISOString(),
68
- error: error instanceof Error ? error.message : String(error),
69
114
  });
70
- });
115
+ }
116
+ catch (error) {
117
+ if (activeSessionSyncs.get(directory) === startedAt)
118
+ activeSessionSyncs.delete(directory);
119
+ throw error;
120
+ }
121
+ void (async () => {
122
+ let statusWrites = Promise.resolve();
123
+ const writePhase = (phase) => {
124
+ statusWrites = statusWrites.then(() => atomicWriteJson(statusPath, {
125
+ status: "running",
126
+ phase,
127
+ pid: process.pid,
128
+ startedAt,
129
+ }));
130
+ };
131
+ try {
132
+ const { manager, error } = await this.getMemorySearchManager(params);
133
+ if (!manager)
134
+ throw new Error(error ?? "memory unavailable");
135
+ const result = await manager.syncSessions(force, writePhase);
136
+ await statusWrites;
137
+ await atomicWriteJson(statusPath, {
138
+ status: "completed",
139
+ startedAt,
140
+ completedAt: new Date().toISOString(),
141
+ ...result,
142
+ });
143
+ }
144
+ catch (error) {
145
+ await statusWrites.catch(() => { });
146
+ try {
147
+ await atomicWriteJson(statusPath, {
148
+ status: "failed",
149
+ startedAt,
150
+ completedAt: new Date().toISOString(),
151
+ error: error instanceof Error ? error.message : String(error),
152
+ });
153
+ }
154
+ catch {
155
+ // The next status read converts the persisted running state to interrupted.
156
+ }
157
+ }
158
+ finally {
159
+ if (activeSessionSyncs.get(directory) === startedAt)
160
+ activeSessionSyncs.delete(directory);
161
+ }
162
+ })().catch(() => { });
71
163
  return { status: "started", startedAt };
72
164
  }
73
- sessionSyncStatus(agentId) {
74
- return this.#sessionSyncStatuses.get(agentId) ?? { status: "idle" };
165
+ async sessionSyncStatus(agentId) {
166
+ const directory = this.#sessionSyncDirectory(agentId);
167
+ const statusPath = join(directory, "session-sync-status.json");
168
+ const status = await readJson(statusPath);
169
+ const running = activeSessionSyncs.get(directory);
170
+ if (running) {
171
+ return status?.status === "running" && status.startedAt === running
172
+ ? { status: "running", phase: status.phase, startedAt: running }
173
+ : { status: "running", phase: "queued", startedAt: running };
174
+ }
175
+ if (!status)
176
+ return { status: "idle" };
177
+ if (status.status !== "running")
178
+ return status;
179
+ return await recoverInterruptedSessionSync(directory, statusPath, status);
75
180
  }
76
181
  async closeMemorySearchManager(params) {
77
182
  const pending = this.#managers.get(params.agentId);
@@ -85,7 +190,7 @@ export class QmdMemoryRuntime {
85
190
  }
86
191
  async #createManager(cfg, agentId) {
87
192
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
88
- const stateDir = join(resolveStateDir(), "agents", agentId, "unblock-memory");
193
+ const stateDir = join(this.#stateRoot, "agents", agentId, "unblock-memory");
89
194
  const fileCorpora = this.#corpora.filter((corpus) => corpus.kind === "files");
90
195
  const sessionCorpus = this.#corpora.find((corpus) => corpus.kind === "sessions");
91
196
  const sources = resolveSources(workspaceDir, fileCorpora);
@@ -115,4 +220,7 @@ export class QmdMemoryRuntime {
115
220
  await manager.start();
116
221
  return manager;
117
222
  }
223
+ #sessionSyncDirectory(agentId) {
224
+ return join(this.#stateRoot, "agents", agentId, "unblock-memory");
225
+ }
118
226
  }
@@ -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.3",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
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.3",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",