@unblocklabs/unblock-memory 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,7 +103,8 @@ eligible.
103
103
  The optional `sessions` corpus reads the current agent's normal OpenClaw SQLite
104
104
  store and indexes its active user/assistant transcript branch. It defaults to
105
105
  channel and group conversations; add `direct` explicitly to include DMs. Run
106
- `memory_sync_sessions` to refresh it. Projections are private derived Markdown
106
+ `memory_sync_sessions` to start a refresh, then use `memory_sync_status` to
107
+ check its progress or result. Projections are private derived Markdown
107
108
  under the agent's `unblock-memory/sessions` state directory and can be rebuilt
108
109
  from OpenClaw at any time. Session results include provider, chat type,
109
110
  conversation identity, and start time. They participate in the same search and
@@ -37,7 +37,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
37
37
  });
38
38
  start(): Promise<void>;
39
39
  sync(params?: MemorySyncParams): Promise<void>;
40
- syncSessions(force?: boolean): Promise<SessionSyncResult>;
40
+ syncSessions(force?: boolean, onPhase?: (phase: "projecting" | "indexing") => void): Promise<SessionSyncResult>;
41
41
  recluster(options?: MemoryReclusterOptions, signal?: AbortSignal): Promise<MemoryAnalysisSummary>;
42
42
  listClusters(limit?: number): Promise<MemoryClusterList>;
43
43
  fetchCluster(params: {
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { mkdir, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import chokidar from "chokidar";
4
4
  import { ensureMemoryAnalysisSchema, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
@@ -151,6 +151,7 @@ export class QmdMemoryManager {
151
151
  #files = 0;
152
152
  #dirty = true;
153
153
  #sessionMetadata = new Map();
154
+ #sessionManifestMtimeNs;
154
155
  constructor(params) {
155
156
  this.#dbPath = params.dbPath;
156
157
  this.#workspaceDir = params.workspaceDir;
@@ -162,12 +163,39 @@ export class QmdMemoryManager {
162
163
  }
163
164
  async start() {
164
165
  if (this.#sessions) {
165
- this.#sessionMetadata = sessionMetadataByPath(await readSessionManifest(this.#sessions.manifestPath));
166
+ await this.#reloadSessionMetadata();
166
167
  }
167
168
  this.#startWatcher();
168
169
  await this.sync({ reason: "first-use" });
169
170
  await this.#watchReady;
170
171
  }
172
+ async #manifestMtimeNs(path) {
173
+ try {
174
+ return (await stat(path, { bigint: true })).mtimeNs;
175
+ }
176
+ catch (error) {
177
+ if (error.code === "ENOENT")
178
+ return undefined;
179
+ throw error;
180
+ }
181
+ }
182
+ async #reloadSessionMetadata() {
183
+ const sessions = this.#sessions;
184
+ if (!sessions)
185
+ return;
186
+ const mtimeNs = await this.#manifestMtimeNs(sessions.manifestPath);
187
+ const manifest = await readSessionManifest(sessions.manifestPath);
188
+ this.#sessionMetadata = sessionMetadataByPath(manifest);
189
+ this.#sessionManifestMtimeNs = mtimeNs;
190
+ }
191
+ async #refreshSessionMetadata() {
192
+ const sessions = this.#sessions;
193
+ if (!sessions)
194
+ return;
195
+ const mtimeNs = await this.#manifestMtimeNs(sessions.manifestPath);
196
+ if (mtimeNs !== this.#sessionManifestMtimeNs)
197
+ await this.#reloadSessionMetadata();
198
+ }
171
199
  #startWatcher() {
172
200
  const paths = [...new Set([...this.#sources.values()]
173
201
  .filter((source) => source.kind === "files")
@@ -291,16 +319,18 @@ export class QmdMemoryManager {
291
319
  };
292
320
  return this.#enqueue(run);
293
321
  }
294
- syncSessions(force = false) {
322
+ syncSessions(force = false, onPhase) {
295
323
  return this.#enqueue(async () => {
296
324
  const sessions = this.#sessions;
297
325
  if (!sessions)
298
326
  throw new Error('memory session sync requires a configured "sessions" corpus');
327
+ onPhase?.("projecting");
299
328
  const store = await this.#getStore();
300
329
  const synced = await syncSessionProjections({
301
330
  ...sessions,
302
331
  force,
303
332
  index: async () => {
333
+ onPhase?.("indexing");
304
334
  const update = await store.update({ collections: [sessions.collection] });
305
335
  this.#cleanupRemovedDocuments?.(update.updated + update.removed);
306
336
  const analysisStore = store;
@@ -380,6 +410,9 @@ export class QmdMemoryManager {
380
410
  opts?.signal?.throwIfAborted();
381
411
  await this.#operationChain;
382
412
  const sessions = this.#sessions;
413
+ if (opts?.sessionFilter && sessions && collections.includes(sessions.collection)) {
414
+ await this.#refreshSessionMetadata();
415
+ }
383
416
  const allowedPaths = opts?.sessionFilter && sessions && collections.includes(sessions.collection)
384
417
  ? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter)
385
418
  : undefined;
@@ -35,6 +35,7 @@ const getParameters = Type.Object({
35
35
  const syncSessionsParameters = Type.Object({
36
36
  force: Type.Optional(Type.Boolean()),
37
37
  }, { additionalProperties: false });
38
+ const syncStatusParameters = Type.Object({}, { additionalProperties: false });
38
39
  function createSearchTool(runtime, ctx) {
39
40
  const active = getContext(ctx);
40
41
  if (!active)
@@ -91,22 +92,26 @@ function createSyncSessionsTool(runtime, ctx) {
91
92
  return {
92
93
  name: "memory_sync_sessions",
93
94
  label: "Sync Memory Sessions",
94
- description: "Project and index this agent's configured OpenClaw session transcripts.",
95
+ description: "Start projecting and indexing this agent's configured OpenClaw session transcripts. Use memory_sync_status to check completion.",
95
96
  parameters: syncSessionsParameters,
96
97
  async execute(_toolCallId, params) {
97
98
  const { force } = Value.Parse(syncSessionsParameters, params);
98
- const { manager, error } = await runtime.getMemorySearchManager(active);
99
- if (!manager)
100
- return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
101
- try {
102
- return jsonResult(await manager.syncSessions(force));
103
- }
104
- catch (syncError) {
105
- return jsonResult({
106
- status: "unavailable",
107
- error: syncError instanceof Error ? syncError.message : String(syncError),
108
- });
109
- }
99
+ return jsonResult(runtime.startSessionSync(active, force));
100
+ },
101
+ };
102
+ }
103
+ function createSyncStatusTool(runtime, ctx) {
104
+ const active = getContext(ctx);
105
+ if (!active)
106
+ return null;
107
+ return {
108
+ name: "memory_sync_status",
109
+ label: "Memory Session Sync Status",
110
+ description: "Check the current or latest session transcript sync.",
111
+ parameters: syncStatusParameters,
112
+ async execute(_toolCallId, params) {
113
+ Value.Parse(syncStatusParameters, params);
114
+ return jsonResult(runtime.sessionSyncStatus(active.agentId));
110
115
  },
111
116
  };
112
117
  }
@@ -281,6 +286,7 @@ export function registerUnblockMemory(api) {
281
286
  api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
282
287
  api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
283
288
  api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), { names: ["memory_sync_sessions"] });
289
+ api.registerTool((ctx) => createSyncStatusTool(runtime, ctx), { names: ["memory_sync_status"] });
284
290
  api.registerTool((ctx) => createReclusterTool(runtime, ctx), { names: ["memory_recluster"] });
285
291
  api.registerTool((ctx) => createListClustersTool(runtime, ctx), { names: ["memory_list_clusters"] });
286
292
  api.registerTool((ctx) => createFetchClusterTool(runtime, ctx), { names: ["memory_fetch_cluster"] });
@@ -2,6 +2,33 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { CorpusConfig } from "./config.js";
3
3
  import type { MemoryPluginRuntimeContract } from "./contracts.js";
4
4
  import { QmdMemoryManager } from "./manager.js";
5
+ import type { SessionSyncResult } from "./session-sync.js";
6
+ export type SessionSyncStatus = {
7
+ status: "idle";
8
+ } | {
9
+ status: "running";
10
+ phase: "queued" | "projecting" | "indexing";
11
+ startedAt: string;
12
+ } | ({
13
+ status: "completed";
14
+ startedAt: string;
15
+ completedAt: string;
16
+ } & SessionSyncResult) | {
17
+ status: "failed";
18
+ startedAt: string;
19
+ completedAt: string;
20
+ error: string;
21
+ };
22
+ export type SessionSyncStartResult = {
23
+ status: "started";
24
+ startedAt: string;
25
+ } | {
26
+ status: "already_running";
27
+ startedAt: string;
28
+ } | {
29
+ status: "unavailable";
30
+ error: string;
31
+ };
5
32
  export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
6
33
  #private;
7
34
  constructor(corpora: readonly CorpusConfig[], analysisExecutable?: string);
@@ -18,6 +45,12 @@ export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
18
45
  resolveMemoryBackendConfig(): {
19
46
  backend: "builtin";
20
47
  };
48
+ classifyWorkspaceMemoryPaths: NonNullable<MemoryPluginRuntimeContract["classifyWorkspaceMemoryPaths"]>;
49
+ startSessionSync(params: {
50
+ cfg: OpenClawConfig;
51
+ agentId: string;
52
+ }, force?: boolean): SessionSyncStartResult;
53
+ sessionSyncStatus(agentId: string): SessionSyncStatus;
21
54
  closeMemorySearchManager(params: {
22
55
  agentId: string;
23
56
  }): Promise<void>;
@@ -4,10 +4,12 @@ import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
4
4
  import { QmdMemoryManager } from "./manager.js";
5
5
  import { resolveTimezone } from "./session-projector.js";
6
6
  import { resolveSessionSource, resolveSources } from "./sources.js";
7
+ import { classifyWorkspaceMemoryPaths } from "./workspace-path-classifier.js";
7
8
  export class QmdMemoryRuntime {
8
9
  #corpora;
9
10
  #analysisExecutable;
10
11
  #managers = new Map();
12
+ #sessionSyncStatuses = new Map();
11
13
  constructor(corpora, analysisExecutable) {
12
14
  this.#corpora = corpora;
13
15
  this.#analysisExecutable = analysisExecutable;
@@ -29,6 +31,48 @@ export class QmdMemoryRuntime {
29
31
  resolveMemoryBackendConfig() {
30
32
  return { backend: "builtin" };
31
33
  }
34
+ classifyWorkspaceMemoryPaths = classifyWorkspaceMemoryPaths;
35
+ startSessionSync(params, force = false) {
36
+ if (!this.#corpora.some((corpus) => corpus.kind === "sessions")) {
37
+ return {
38
+ status: "unavailable",
39
+ error: 'memory session sync requires a configured "sessions" corpus',
40
+ };
41
+ }
42
+ const current = this.sessionSyncStatus(params.agentId);
43
+ if (current.status === "running") {
44
+ return { status: "already_running", startedAt: current.startedAt };
45
+ }
46
+ 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",
66
+ startedAt,
67
+ completedAt: new Date().toISOString(),
68
+ error: error instanceof Error ? error.message : String(error),
69
+ });
70
+ });
71
+ return { status: "started", startedAt };
72
+ }
73
+ sessionSyncStatus(agentId) {
74
+ return this.#sessionSyncStatuses.get(agentId) ?? { status: "idle" };
75
+ }
32
76
  async closeMemorySearchManager(params) {
33
77
  const pending = this.#managers.get(params.agentId);
34
78
  this.#managers.delete(params.agentId);
@@ -0,0 +1,4 @@
1
+ import type { MemoryPluginRuntimeContract } from "./contracts.js";
2
+ type ClassifyWorkspaceMemoryPaths = NonNullable<MemoryPluginRuntimeContract["classifyWorkspaceMemoryPaths"]>;
3
+ export declare const classifyWorkspaceMemoryPaths: ClassifyWorkspaceMemoryPaths;
4
+ export {};
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime";
4
+ import { readMemoryArtifactProvenance } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
5
+ export const classifyWorkspaceMemoryPaths = async (params) => await Promise.all(params.relativePaths.map(async (relativePath) => {
6
+ let workspacePath;
7
+ let filePath;
8
+ try {
9
+ [workspacePath, filePath] = await Promise.all([
10
+ fs.realpath(params.workspaceDir),
11
+ fs.realpath(path.resolve(params.workspaceDir, relativePath)),
12
+ ]);
13
+ }
14
+ catch {
15
+ return { relativePath, originClass: "untrusted" };
16
+ }
17
+ if (!isPathStrictlyInside(workspacePath, filePath)) {
18
+ return { relativePath, originClass: "untrusted" };
19
+ }
20
+ const workspaceRelativePath = path.relative(workspacePath, filePath);
21
+ const segments = workspaceRelativePath.split(path.sep);
22
+ const curatedRoot = segments.length === 1 &&
23
+ ["MEMORY.md", "memory.md", "USER.md"].includes(segments[0]);
24
+ if ((segments.length === 1 && ["DREAMS.md", "dreams.md"].includes(segments[0])) ||
25
+ (segments[0] === "memory" && ["dreaming", ".dreams"].includes(segments[1]))) {
26
+ return { relativePath, originClass: "system" };
27
+ }
28
+ const isWorkspaceMemory = curatedRoot ||
29
+ (segments[0] === "memory" && segments.at(-1)?.endsWith(".md") === true);
30
+ const normalizedPath = workspaceRelativePath.replaceAll(path.sep, "/");
31
+ const recorded = isWorkspaceMemory
32
+ ? await readMemoryArtifactProvenance({
33
+ workspaceDir: params.workspaceDir,
34
+ relativePath: normalizedPath,
35
+ })
36
+ : undefined;
37
+ return {
38
+ relativePath,
39
+ originClass: recorded?.originClass ?? (isWorkspaceMemory ? "agent" : "untrusted"),
40
+ };
41
+ }));
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
8
- "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
8
+ "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_sync_status", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
9
9
  "toolMetadata": {
10
10
  "memory_sync_sessions": { "sideEffecting": true },
11
+ "memory_sync_status": { "replaySafe": true },
11
12
  "memory_recluster": { "sideEffecting": true },
12
13
  "memory_list_clusters": { "replaySafe": true },
13
14
  "memory_fetch_cluster": { "replaySafe": true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": "github:unblocklabs-ai/qmd#937cd82f9b41818d9eb5f78b94394cb5023b5daf",
33
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.1/unblocklabs-qmd-2.9.1.tgz",
34
34
  "chokidar": "5.0.0",
35
35
  "picomatch": "^4.0.5",
36
36
  "typebox": "1.3.6"