@unblocklabs/unblock-memory 0.1.2 → 0.2.0

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
@@ -13,7 +13,7 @@ index. It does not re-embed memory, copy vectors, or create another database.
13
13
  From npm:
14
14
 
15
15
  ```bash
16
- openclaw plugins install @unblocklabs/unblock-memory
16
+ openclaw plugins install npm:@unblocklabs/unblock-memory
17
17
  ```
18
18
 
19
19
  Or directly from GitHub:
@@ -22,10 +22,15 @@ Or directly from GitHub:
22
22
  openclaw plugins install git:github.com/unblocklabs-ai/unblock-memory
23
23
  ```
24
24
 
25
+ Install the plugin once on each OpenClaw host, not once per agent. It installs
26
+ its pinned `@unblocklabs/qmd` runtime dependency automatically, so QMD does not
27
+ need to be installed separately. Each agent gets its own QMD index when it first
28
+ uses memory.
29
+
25
30
  ## Configuration
26
31
 
27
- Select the plugin as the memory provider and list any exact Markdown files,
28
- directories, or globs to index:
32
+ Select the plugin as the memory provider and group exact Markdown files,
33
+ directories, or globs into named corpora:
29
34
 
30
35
  ```json5
31
36
  {
@@ -34,14 +39,26 @@ directories, or globs to index:
34
39
  entries: {
35
40
  "unblock-memory": {
36
41
  config: {
37
- paths: [
38
- "MEMORY.md",
39
- "USER.md",
40
- "memory/**/*.md",
41
- "/absolute/shared/**/*.md",
42
+ corpora: [
43
+ {
44
+ name: "memory",
45
+ kind: "files",
46
+ paths: ["MEMORY.md", "USER.md", "memory/**/*.md"],
47
+ },
48
+ {
49
+ name: "projects",
50
+ kind: "files",
51
+ paths: ["/absolute/shared/**/*.md"],
52
+ },
53
+ {
54
+ name: "sessions",
55
+ kind: "sessions",
56
+ chatTypes: ["channel", "group"],
57
+ },
42
58
  ],
59
+ // Optional: omit unless the local analysis worker is installed.
43
60
  analysis: {
44
- executable: "/absolute/path/to/unblock-memory-analysis",
61
+ executable: "/absolute/path/to/unblock-cluster/bin/unblock-memory-analysis",
45
62
  },
46
63
  },
47
64
  },
@@ -51,9 +68,47 @@ directories, or globs to index:
51
68
  ```
52
69
 
53
70
  Relative entries resolve from each agent workspace. Absolute paths and `~/`
54
- paths are supported. A directory means recursive Markdown. When `paths` is
55
- omitted, the defaults are `MEMORY.md`, `USER.md`, and `memory/**/*.md`; an
56
- explicit array replaces those defaults.
71
+ paths are supported. A directory means recursive Markdown. When `corpora` is
72
+ omitted, the plugin creates a `memory` corpus containing `MEMORY.md`, `USER.md`,
73
+ and `memory/**/*.md`. Explicit configuration must include exactly one `memory`
74
+ corpus; other unique names may be added for custom material.
75
+
76
+ `memory_search` searches every configured corpus by default. Pass
77
+ `corpora: ["projects"]` to search selected corpora or `corpora: ["all"]` to
78
+ request all of them explicitly. Search results include their corpus name and
79
+ remain readable by passing the returned `qmd://` path to `memory_get`.
80
+
81
+ Use `sessionFilter` to restrict session results by metadata while leaving file
82
+ corpora searchable. Supported fields are `startedFrom` and `startedTo`
83
+ (inclusive ISO 8601 timestamps), `provider`, `chatType`, `accountId`, and
84
+ `conversationId`:
85
+
86
+ ```json
87
+ {
88
+ "query": "deployment decision",
89
+ "sessionFilter": {
90
+ "startedFrom": "2026-08-01T00:00:00Z",
91
+ "provider": "slack",
92
+ "chatType": "channel"
93
+ }
94
+ }
95
+ ```
96
+
97
+ Provider matching is case-normalized; `chatType` uses the lowercase values
98
+ shown in the configuration example. Account and conversation IDs are trimmed
99
+ and matched exactly. When only `sessions` is selected and no sessions match,
100
+ search returns no results. With other corpora selected, their results remain
101
+ eligible.
102
+
103
+ The optional `sessions` corpus reads the current agent's normal OpenClaw SQLite
104
+ store and indexes its active user/assistant transcript branch. It defaults to
105
+ channel and group conversations; add `direct` explicitly to include DMs. Run
106
+ `memory_sync_sessions` to refresh it. Projections are private derived Markdown
107
+ under the agent's `unblock-memory/sessions` state directory and can be rebuilt
108
+ from OpenClaw at any time. Session results include provider, chat type,
109
+ conversation identity, and start time. They participate in the same search and
110
+ clustering index as file memory. This phase does not sync sessions at startup or
111
+ on a schedule; refreshes are manual through `memory_sync_sessions`.
57
112
 
58
113
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
59
114
  equivalent configured OpenClaw state directory). The first lookup builds the
@@ -62,12 +117,30 @@ refresh.
62
117
 
63
118
  ## Memory analysis
64
119
 
65
- Analysis is opt-in and requires the separately installed local analysis worker.
66
- Set `analysis.executable` to its absolute path. The plugin invokes that file
67
- directly with `--db <the agent's known index path>` and, when requested,
68
- `--config-json <validated clustering options>`. Agents cannot choose a database,
120
+ Analysis is opt-in. Core indexing, `memory_search`, and `memory_get` need only
121
+ Unblock Memory and its automatically installed QMD dependency. To enable
122
+ clustering, install the
123
+ [`unblock-cluster`](https://github.com/unblocklabs-ai/unblock-cluster) worker once
124
+ on the same host:
125
+
126
+ ```bash
127
+ git clone https://github.com/unblocklabs-ai/unblock-cluster.git
128
+ cd unblock-cluster
129
+ python3 -m venv .venv
130
+ .venv/bin/python -m pip install -r requirements.txt
131
+ ```
132
+
133
+ Set `analysis.executable` to the absolute path of
134
+ `bin/unblock-memory-analysis` in that checkout. One worker installation can
135
+ serve every agent on the host. The plugin invokes it directly with
136
+ `--db <the agent's known index path>` and, when requested, a validated
137
+ `--config-json <clustering options>` payload. Agents cannot choose a database,
69
138
  executable, shell command, or arbitrary arguments.
70
139
 
140
+ Without the worker, `memory_list_clusters` reports that memory has not been
141
+ analyzed and `memory_recluster` reports that analysis is unavailable. Ordinary
142
+ memory search and reads continue to work.
143
+
71
144
  The analysis worker reads QMD's existing semantic vectors and writes only
72
145
  derived results into three namespaced tables in that same `index.sqlite`:
73
146
 
@@ -100,6 +173,5 @@ A failed rebuild leaves the stale result intact, while a successful rebuild
100
173
  atomically replaces it. Analysis is never scheduled automatically. If the worker
101
174
  is absent or fails, `memory_search` and `memory_get` continue to work.
102
175
 
103
- Session transcripts are intentionally out of scope for this first version. Existing
104
- `unblock-qmd` indexes are derived caches and may be left in place; Unblock Memory
105
- rebuilds its new index from the configured workspace Markdown.
176
+ Existing `unblock-qmd` indexes are derived caches and may be left in place;
177
+ Unblock Memory rebuilds its own index from configured corpora.
@@ -1,8 +1,22 @@
1
- export declare const DEFAULT_PATHS: readonly ["MEMORY.md", "USER.md", "memory/**/*.md"];
2
- export type UnblockMemoryConfig = {
1
+ export type FileCorpusConfig = {
2
+ name: string;
3
+ kind: "files";
3
4
  paths: readonly string[];
5
+ };
6
+ declare const CHAT_TYPES: readonly ["channel", "group", "direct"];
7
+ export type ChatType = typeof CHAT_TYPES[number];
8
+ type SessionCorpusConfig = {
9
+ name: "sessions";
10
+ kind: "sessions";
11
+ chatTypes: readonly ChatType[];
12
+ };
13
+ export type CorpusConfig = FileCorpusConfig | SessionCorpusConfig;
14
+ export declare const DEFAULT_CORPORA: readonly FileCorpusConfig[];
15
+ export type UnblockMemoryConfig = {
16
+ corpora: readonly CorpusConfig[];
4
17
  analysis: {
5
18
  executable?: string;
6
19
  };
7
20
  };
8
21
  export declare function resolveConfig(value: unknown): UnblockMemoryConfig;
22
+ export {};
@@ -1,27 +1,90 @@
1
1
  import { isAbsolute } from "node:path";
2
- export const DEFAULT_PATHS = ["MEMORY.md", "USER.md", "memory/**/*.md"];
3
- export function resolveConfig(value) {
4
- if (!value || typeof value !== "object") {
5
- return { paths: DEFAULT_PATHS, analysis: {} };
2
+ const DEFAULT_PATHS = ["MEMORY.md", "USER.md", "memory/**/*.md"];
3
+ const CHAT_TYPES = ["channel", "group", "direct"];
4
+ export const DEFAULT_CORPORA = [{
5
+ name: "memory",
6
+ kind: "files",
7
+ paths: DEFAULT_PATHS,
8
+ }];
9
+ function assertOnlyKeys(value, allowed, label) {
10
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
11
+ if (unknown)
12
+ throw new Error(`unblock-memory ${label} has unknown property: ${unknown}`);
13
+ }
14
+ function resolveCorpora(value) {
15
+ if (value === undefined)
16
+ return DEFAULT_CORPORA;
17
+ if (!Array.isArray(value) || value.length === 0) {
18
+ throw new Error("unblock-memory corpora must be a non-empty array");
6
19
  }
7
- const config = value;
8
- let paths = DEFAULT_PATHS;
9
- if (config.paths !== undefined) {
10
- if (!Array.isArray(config.paths) || !config.paths.every((entry) => typeof entry === "string" && entry.trim())) {
11
- throw new Error("unblock-memory paths must be an array of non-empty strings");
20
+ const names = new Set();
21
+ const corpora = value.map((entry, index) => {
22
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
23
+ throw new Error(`unblock-memory corpora[${index}] must be an object`);
24
+ }
25
+ const corpus = entry;
26
+ if (typeof corpus.name !== "string" || !corpus.name.trim()) {
27
+ throw new Error(`unblock-memory corpora[${index}].name must be a non-empty string`);
28
+ }
29
+ const name = corpus.name.trim();
30
+ if (name === "all") {
31
+ throw new Error(`unblock-memory corpus name is reserved: ${name}`);
32
+ }
33
+ if (names.has(name))
34
+ throw new Error(`unblock-memory corpus names must be unique: ${name}`);
35
+ names.add(name);
36
+ if (corpus.kind === "sessions") {
37
+ assertOnlyKeys(corpus, ["name", "kind", "chatTypes"], `corpora[${index}]`);
38
+ if (name !== "sessions") {
39
+ throw new Error('unblock-memory session corpus must be named "sessions"');
40
+ }
41
+ const chatTypes = corpus.chatTypes ?? ["channel", "group"];
42
+ if (!Array.isArray(chatTypes) || chatTypes.length === 0 ||
43
+ !chatTypes.every((chatType) => CHAT_TYPES.includes(chatType))) {
44
+ throw new Error(`unblock-memory corpus sessions chatTypes must contain channel, group, or direct`);
45
+ }
46
+ return { name: "sessions", kind: "sessions", chatTypes: [...new Set(chatTypes)] };
47
+ }
48
+ assertOnlyKeys(corpus, ["name", "kind", "paths"], `corpora[${index}]`);
49
+ if (name === "sessions") {
50
+ throw new Error('unblock-memory corpus named "sessions" must have kind "sessions"');
12
51
  }
13
- paths = config.paths.map((entry) => entry.trim());
52
+ if (corpus.kind !== "files") {
53
+ throw new Error(`unblock-memory corpus ${name} must have kind "files" or "sessions"`);
54
+ }
55
+ if (!Array.isArray(corpus.paths) || corpus.paths.length === 0 ||
56
+ !corpus.paths.every((path) => typeof path === "string" && path.trim())) {
57
+ throw new Error(`unblock-memory corpus ${name} paths must be a non-empty array of non-empty strings`);
58
+ }
59
+ return { name, kind: "files", paths: corpus.paths.map((path) => path.trim()) };
60
+ });
61
+ if (corpora.filter((corpus) => corpus.name === "memory").length !== 1) {
62
+ throw new Error('unblock-memory corpora must contain exactly one corpus named "memory"');
14
63
  }
64
+ return corpora;
65
+ }
66
+ export function resolveConfig(value) {
67
+ if (value === undefined || value === null) {
68
+ return { corpora: DEFAULT_CORPORA, analysis: {} };
69
+ }
70
+ if (typeof value !== "object" || Array.isArray(value)) {
71
+ throw new Error("unblock-memory config must be an object");
72
+ }
73
+ const config = value;
74
+ assertOnlyKeys(config, ["corpora", "analysis"], "config");
75
+ const corpora = resolveCorpora(config.corpora);
15
76
  if (config.analysis === undefined)
16
- return { paths, analysis: {} };
17
- if (!config.analysis || typeof config.analysis !== "object") {
77
+ return { corpora, analysis: {} };
78
+ if (!config.analysis || typeof config.analysis !== "object" || Array.isArray(config.analysis)) {
18
79
  throw new Error("unblock-memory analysis must be an object");
19
80
  }
20
- const configured = config.analysis.executable;
81
+ const analysis = config.analysis;
82
+ assertOnlyKeys(analysis, ["executable"], "analysis");
83
+ const configured = analysis.executable;
21
84
  if (configured === undefined)
22
- return { paths, analysis: {} };
85
+ return { corpora, analysis: {} };
23
86
  if (typeof configured !== "string" || !configured.trim() || !isAbsolute(configured.trim())) {
24
87
  throw new Error("unblock-memory analysis.executable must be an absolute non-empty path");
25
88
  }
26
- return { paths, analysis: { executable: configured.trim() } };
89
+ return { corpora, analysis: { executable: configured.trim() } };
27
90
  }
@@ -1,9 +1,27 @@
1
1
  import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core";
2
+ import type { SessionMetadata } from "./session-projector.js";
3
+ import type { ChatType } from "./config.js";
2
4
  export type MemoryPluginRuntimeContract = NonNullable<MemoryPluginCapability["runtime"]>;
3
5
  type ManagerLookup = Awaited<ReturnType<MemoryPluginRuntimeContract["getMemorySearchManager"]>>;
4
6
  export type MemorySearchManagerContract = NonNullable<ManagerLookup["manager"]>;
5
7
  export type MemoryProviderStatus = ReturnType<MemorySearchManagerContract["status"]>;
6
8
  export type MemorySearchResult = Awaited<ReturnType<MemorySearchManagerContract["search"]>>[number];
9
+ export type CorpusMemorySearchResult = MemorySearchResult & {
10
+ corpus: string;
11
+ session?: SessionMetadata;
12
+ };
13
+ export type SessionSearchFilter = {
14
+ startedFrom?: string;
15
+ startedTo?: string;
16
+ provider?: string;
17
+ chatType?: ChatType;
18
+ accountId?: string;
19
+ conversationId?: string;
20
+ };
21
+ export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
22
+ corpora?: readonly string[];
23
+ sessionFilter?: SessionSearchFilter;
24
+ };
7
25
  export type MemoryEmbeddingProbeResult = Awaited<ReturnType<MemorySearchManagerContract["probeEmbeddingAvailability"]>>;
8
26
  export type MemorySyncParams = Parameters<NonNullable<MemorySearchManagerContract["sync"]>>[0];
9
27
  export type MemoryReadResult = {
@@ -1,8 +1,20 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
2
  import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryReclusterOptions } from "./analysis.js";
3
- import type { MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySearchResult, MemorySyncParams } from "./contracts.js";
3
+ import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
+ import type { ChatType } from "./config.js";
5
+ import { type SessionSyncResult } from "./session-sync.js";
4
6
  import { type ResolvedSource } from "./sources.js";
5
7
  export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "searchLex" | "vsearch" | "get" | "getDocumentBody" | "close">;
8
+ export type ManagerSessionConfig = {
9
+ agentId: string;
10
+ agentName: string;
11
+ chatTypes: readonly ChatType[];
12
+ collection: string;
13
+ databasePath: string;
14
+ manifestPath: string;
15
+ outputDir: string;
16
+ timezone: string;
17
+ };
6
18
  export declare function enableSecureDelete(store: QMDStore): void;
7
19
  export declare function cleanupRemovedDocuments(store: QMDStore, changedDocuments?: number): number;
8
20
  export declare function pruneStaleCollections(store: QMDStore, configuredCollections: ReadonlySet<string>): Promise<number>;
@@ -21,22 +33,18 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
21
33
  storeFactory?: () => Promise<ManagerStore>;
22
34
  analysisExecutable?: string;
23
35
  analysisRunner?: AnalysisRunner;
36
+ sessions?: ManagerSessionConfig;
24
37
  });
25
38
  start(): Promise<void>;
26
39
  sync(params?: MemorySyncParams): Promise<void>;
40
+ syncSessions(force?: boolean): Promise<SessionSyncResult>;
27
41
  recluster(options?: MemoryReclusterOptions, signal?: AbortSignal): Promise<MemoryAnalysisSummary>;
28
42
  listClusters(limit?: number): Promise<MemoryClusterList>;
29
43
  fetchCluster(params: {
30
44
  clusterId: string;
31
45
  topK?: number;
32
46
  }): Promise<MemoryClusterDetail>;
33
- search(query: string, opts?: {
34
- maxResults?: number;
35
- minScore?: number;
36
- lexicalOnly?: boolean;
37
- sources?: Array<"memory" | "sessions">;
38
- signal?: AbortSignal;
39
- }): Promise<MemorySearchResult[]>;
47
+ search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
40
48
  readFile(params: {
41
49
  relPath: string;
42
50
  from?: number;