@unblocklabs/unblock-memory 0.3.10 → 0.3.12

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,7 +2,7 @@
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 direct QMD vector
5
+ and `memory_get` tools. Search uses semantic chunking and direct QMD vector
6
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
@@ -55,6 +55,7 @@ directories, or globs into named corpora:
55
55
  name: "sessions",
56
56
  kind: "sessions",
57
57
  chatTypes: ["channel", "group"],
58
+ maxExpandedTokens: 500,
58
59
  },
59
60
  {
60
61
  name: "knowledge",
@@ -237,20 +238,36 @@ eligible.
237
238
 
238
239
  The optional `sessions` corpus reads the current agent's normal OpenClaw SQLite
239
240
  store and indexes its active user/assistant transcript branch. It defaults to
240
- channel and group conversations; add `direct` explicitly to include DMs. Run
241
+ channel and group conversations; add `direct` explicitly to include DMs. A
242
+ session vector hit expands to its complete user/assistant turn when the turn
243
+ fits `maxExpandedTokens`, or to its complete enclosing message when only that
244
+ fits. The default is `500`; the original semantic chunk is preserved when
245
+ neither complete context fits, so expansion never clips the matched evidence.
246
+ Run
241
247
  `memory_sync_sessions` to start a refresh, then use `memory_sync_status` to
242
248
  check its progress or result. The read-only adapter explicitly supports OpenClaw
243
249
  agent database schemas 17, 18, and 19 and validates its required columns before
244
250
  reading. Projections are private derived Markdown under the
245
251
  agent's `unblock-memory/sessions` state directory and can be rebuilt from
246
252
  OpenClaw at any time. Their embedded text contains only `# Transcript` and
247
- timestamped speaker messages; filtering metadata remains in the session
248
- manifest. The projected file modification time matches the session start time
249
- for meaningful chronological cluster reads. Session results include provider,
250
- chat type, conversation identity, and start time as an ISO 8601 timestamp. They
251
- participate in the same search and clustering index as file memory. This phase
252
- does not sync sessions at startup or on a schedule; refreshes are manual through
253
- `memory_sync_sessions`.
253
+ role-labeled, timestamped speaker messages; filtering metadata remains in the
254
+ session manifest. The projected file modification time matches the session
255
+ start time for meaningful chronological cluster reads. Session results include
256
+ provider, chat type, conversation identity, and start time as an ISO 8601 timestamp. They
257
+ participate in the same search and clustering index as file memory. The plugin
258
+ automatically refreshes each configured agent's sessions every 15 minutes while
259
+ the Gateway runs. Set `syncIntervalMinutes` on the `sessions` corpus to an integer
260
+ from `1` to `1440`, or `0` for manual-only syncing. For example:
261
+
262
+ ```json
263
+ { "name": "sessions", "kind": "sessions", "syncIntervalMinutes": 15 }
264
+ ```
265
+
266
+ The first refresh runs after one interval, not during startup. Restart the
267
+ Gateway after changing the interval. Refreshes are incremental; an already-running
268
+ sync is skipped, and failures are visible through `memory_sync_status` and retried
269
+ at the next interval. `memory_sync_sessions` still provides an immediate manual
270
+ refresh. Syncing and embedding run inside the Gateway process, without an LLM turn.
254
271
 
255
272
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
256
273
  equivalent configured OpenClaw state directory). Durable agent-supplied event
@@ -262,7 +279,7 @@ Markdown filesystem changes queue a debounced, serialized background refresh.
262
279
 
263
280
  Analysis is opt-in. Core indexing, `memory_search`, and `memory_get` need only
264
281
  Unblock Memory and its automatically installed QMD dependency. To enable
265
- clustering, install the
282
+ clustering, install the public
266
283
  [`unblock-cluster`](https://github.com/unblocklabs-ai/unblock-cluster) worker once
267
284
  on the same host:
268
285
 
@@ -270,7 +287,7 @@ on the same host:
270
287
  git clone https://github.com/unblocklabs-ai/unblock-cluster.git
271
288
  cd unblock-cluster
272
289
  python3 -m venv .venv
273
- .venv/bin/python -m pip install -r requirements.txt
290
+ .venv/bin/python -m pip install -r requirements-analysis.txt
274
291
  ```
275
292
 
276
293
  Set `analysis.executable` to the absolute path of
@@ -286,11 +303,12 @@ analyzed and `memory_recluster` reports that analysis is unavailable. Ordinary
286
303
  memory search and reads continue to work.
287
304
 
288
305
  The analysis worker reads QMD's existing semantic vectors and writes only
289
- derived results into three namespaced tables in that same `index.sqlite`:
306
+ derived results into four namespaced tables in that same `index.sqlite`:
290
307
 
291
308
  - `memory_analysis_runs`
292
309
  - `memory_analysis_clusters`
293
310
  - `memory_analysis_memberships`
311
+ - `memory_analysis_duplicate_occurrences`
294
312
 
295
313
  Unblock Memory exposes:
296
314
 
@@ -14,6 +14,8 @@ type SessionCorpusConfig = {
14
14
  name: "sessions";
15
15
  kind: "sessions";
16
16
  chatTypes: readonly ChatType[];
17
+ maxExpandedTokens: number;
18
+ syncIntervalMinutes: number;
17
19
  };
18
20
  export type CorpusConfig = FileCorpusConfig | SkillCorpusConfig | SessionCorpusConfig;
19
21
  export declare const DEFAULT_CORPORA: readonly FileCorpusConfig[];
@@ -1,5 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  const DEFAULT_PATHS = ["MEMORY.md", "USER.md", "memory/**/*.md"];
3
+ const DEFAULT_SESSION_MAX_EXPANDED_TOKENS = 500;
4
+ const MAX_SESSION_MAX_EXPANDED_TOKENS = 10_000;
3
5
  const CHAT_TYPES = ["channel", "group", "direct"];
4
6
  export const DEFAULT_CORPORA = [
5
7
  {
@@ -59,7 +61,7 @@ function resolveCorpora(value) {
59
61
  return { name: "skills", kind: "skills", paths: corpus.paths.map((path) => path.trim()) };
60
62
  }
61
63
  if (corpus.kind === "sessions") {
62
- assertOnlyKeys(corpus, ["name", "kind", "chatTypes"], `corpora[${index}]`);
64
+ assertOnlyKeys(corpus, ["name", "kind", "chatTypes", "maxExpandedTokens", "syncIntervalMinutes"], `corpora[${index}]`);
63
65
  if (name !== "sessions") {
64
66
  throw new Error('unblock-memory session corpus must be named "sessions"');
65
67
  }
@@ -69,10 +71,17 @@ function resolveCorpora(value) {
69
71
  !chatTypes.every((chatType) => CHAT_TYPES.includes(chatType))) {
70
72
  throw new Error(`unblock-memory corpus sessions chatTypes must contain channel, group, or direct`);
71
73
  }
74
+ const syncIntervalMinutes = corpus.syncIntervalMinutes ?? 15;
75
+ if (typeof syncIntervalMinutes !== "number" || !Number.isInteger(syncIntervalMinutes) ||
76
+ syncIntervalMinutes < 0 || syncIntervalMinutes > 1440) {
77
+ throw new Error("unblock-memory corpus sessions syncIntervalMinutes must be an integer between 0 and 1440");
78
+ }
72
79
  return {
73
80
  name: "sessions",
81
+ syncIntervalMinutes,
74
82
  kind: "sessions",
75
83
  chatTypes: [...new Set(chatTypes)],
84
+ maxExpandedTokens: positiveInteger(corpus.maxExpandedTokens, DEFAULT_SESSION_MAX_EXPANDED_TOKENS, "corpus sessions maxExpandedTokens", MAX_SESSION_MAX_EXPANDED_TOKENS),
76
85
  };
77
86
  }
78
87
  assertOnlyKeys(corpus, ["name", "kind", "paths"], `corpora[${index}]`);
@@ -1,4 +1,4 @@
1
- import type { QMDStore } from "@unblocklabs/qmd";
1
+ import type { QMDStore, VectorSearchResult } from "@unblocklabs/qmd";
2
2
  import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
3
3
  import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemoryRequestContext, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
4
  import type { ChatType } from "./config.js";
@@ -10,6 +10,7 @@ export type ManagerSessionConfig = {
10
10
  agentId: string;
11
11
  agentName: string;
12
12
  chatTypes: readonly ChatType[];
13
+ maxExpandedTokens: number;
13
14
  collection: string;
14
15
  databasePath: string;
15
16
  manifestPath: string;
@@ -30,6 +31,10 @@ export declare function buildReadResult(params: {
30
31
  from?: number;
31
32
  lines?: number;
32
33
  }): MemoryReadResult;
34
+ export declare function expandSessionSearchHit(result: Pick<VectorSearchResult, "body" | "bestChunk" | "chunkPos" | "chunkLen">, maxTokens: number, countTokens: (text: string) => Promise<number>): Promise<{
35
+ text: string;
36
+ position: number;
37
+ }>;
33
38
  export declare class QmdMemoryManager implements MemorySearchManagerContract {
34
39
  #private;
35
40
  constructor(params: {
@@ -6,6 +6,7 @@ import picomatch from "picomatch";
6
6
  import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
7
7
  import { CurationStore, chunkFingerprint, } from "./curation.js";
8
8
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
9
+ import { sessionContextSpans } from "./session-projector.js";
9
10
  import { parseSafeVirtualPath, sourceMatchesPath } from "./sources.js";
10
11
  const DEFAULT_READ_LINES = 120;
11
12
  const MAX_READ_CHARS = 12_000;
@@ -174,12 +175,27 @@ export function buildReadResult(params) {
174
175
  ...(nextFrom ? { nextFrom } : {}),
175
176
  };
176
177
  }
177
- function lineSpan(result) {
178
- const before = result.body.slice(0, result.chunkPos);
178
+ function lineSpan(body, position, text) {
179
+ const before = body.slice(0, position);
179
180
  const startLine = before.split("\n").length;
180
- const endLine = startLine + Math.max(0, result.bestChunk.split("\n").length - 1);
181
+ const endLine = startLine + Math.max(0, text.split("\n").length - 1);
181
182
  return { startLine, endLine };
182
183
  }
184
+ export async function expandSessionSearchHit(result, maxTokens, countTokens) {
185
+ const leaf = { text: result.bestChunk, position: result.chunkPos };
186
+ const spans = sessionContextSpans(result.body, result.chunkPos);
187
+ if (!spans)
188
+ return leaf;
189
+ const leafEnd = result.chunkPos + result.chunkLen;
190
+ for (const span of [spans.turn, spans.message]) {
191
+ if (span.start > result.chunkPos || span.end < leafEnd)
192
+ continue;
193
+ const text = result.body.slice(span.start, span.end).trimEnd();
194
+ if (await countTokens(text) <= maxTokens)
195
+ return { text, position: span.start };
196
+ }
197
+ return leaf;
198
+ }
183
199
  function lexicalResult(hit, corpus, session) {
184
200
  const body = hit.body ?? hit.title;
185
201
  const endLine = Math.max(1, body.split("\n").length);
@@ -703,30 +719,36 @@ export class QmdMemoryManager {
703
719
  allowedPaths,
704
720
  expand: false,
705
721
  });
706
- return hits.flatMap((hit) => {
722
+ const tokenizer = store.internal?.llm;
723
+ const results = [];
724
+ for (const hit of hits) {
707
725
  const collection = /^qmd:\/\/([^/]+)\//.exec(hit.file)?.[1];
708
726
  const corpus = collection ? this.#sources.get(collection)?.corpus : undefined;
709
727
  if (!corpus)
710
- return [];
711
- const span = lineSpan(hit);
728
+ continue;
712
729
  const relativePath = collection && hit.file.startsWith(`qmd://${collection}/`)
713
730
  ? hit.file.slice(`qmd://${collection}/`.length)
714
731
  : undefined;
715
732
  const session = corpus === "sessions" && relativePath
716
733
  ? this.#sessionMetadata.get(relativePath)
717
734
  : undefined;
718
- return [{
719
- path: hit.file,
720
- ...span,
721
- score: hit.score,
722
- vectorScore: hit.score,
723
- snippet: hit.bestChunk,
724
- source: "memory",
725
- corpus,
726
- ...(session ? { session } : {}),
727
- citation: `${hit.displayPath}#L${span.startLine}-L${span.endLine}`,
728
- }];
729
- });
735
+ const selected = corpus === "sessions" && this.#sessions && tokenizer
736
+ ? await expandSessionSearchHit(hit, this.#sessions.maxExpandedTokens, (text) => tokenizer.countTokens(text))
737
+ : { text: hit.bestChunk, position: hit.chunkPos };
738
+ const span = lineSpan(hit.body, selected.position, selected.text);
739
+ results.push({
740
+ path: hit.file,
741
+ ...span,
742
+ score: hit.score,
743
+ vectorScore: hit.score,
744
+ snippet: selected.text,
745
+ source: "memory",
746
+ corpus,
747
+ ...(session ? { session } : {}),
748
+ citation: `${hit.displayPath}#L${span.startLine}-L${span.endLine}`,
749
+ });
750
+ }
751
+ return results;
730
752
  }
731
753
  async searchSkills(query, minScore, limit) {
732
754
  const collections = this.#skillCollectionNames();
@@ -403,6 +403,12 @@ export function registerUnblockMemory(api) {
403
403
  runtime,
404
404
  };
405
405
  api.registerMemoryCapability(capability);
406
+ if (config.corpora.some((corpus) => corpus.kind === "sessions" && corpus.syncIntervalMinutes > 0)) {
407
+ api.on("gateway_start", () => runtime.startSessionSyncSchedule(api.config, (error) => {
408
+ api.logger.warn(`unblock-memory scheduled session sync could not start: ${String(error)}`);
409
+ }));
410
+ api.on("gateway_stop", () => runtime.stopSessionSyncSchedule());
411
+ }
406
412
  if (config.people.enabled) {
407
413
  const peopleStores = new PeopleStores({
408
414
  maxOpenTodos: config.people.todos.maxOpen,
@@ -40,6 +40,8 @@ type StoredRunningSessionSync = Extract<StoredSessionSyncStatus, {
40
40
  export declare function recoverInterruptedSessionSync(directory: string, statusPath: string, stale: StoredRunningSessionSync): Promise<SessionSyncStatus>;
41
41
  export declare class QmdMemoryRuntime implements MemoryPluginRuntimeContract {
42
42
  #private;
43
+ startSessionSyncSchedule(cfg: OpenClawConfig, onError: (error: unknown) => void): void;
44
+ stopSessionSyncSchedule(): void;
43
45
  constructor(corpora: readonly CorpusConfig[], options?: {
44
46
  analysisExecutable?: string;
45
47
  keepEmbeddingModelWarm?: boolean;
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { resolveAgentDir, resolveAgentWorkspaceDir, resolveStateDir, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
5
- import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
5
+ import { listAgentIds, resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
6
6
  import { QmdMemoryManager } from "./manager.js";
7
7
  import { resolveTimezone } from "./session-projector.js";
8
8
  import { resolveConfiguredSkillPath, resolveSessionSource, resolveSources } from "./sources.js";
@@ -68,6 +68,24 @@ export class QmdMemoryRuntime {
68
68
  #keepEmbeddingModelWarm;
69
69
  #stateRoot;
70
70
  #managers = new Map();
71
+ #sessionSyncTimer;
72
+ startSessionSyncSchedule(cfg, onError) {
73
+ this.stopSessionSyncSchedule();
74
+ const sessions = this.#corpora.find((corpus) => corpus.kind === "sessions");
75
+ if (!sessions?.syncIntervalMinutes)
76
+ return;
77
+ this.#sessionSyncTimer = setInterval(() => {
78
+ for (const agentId of listAgentIds(cfg)) {
79
+ void this.startSessionSync({ cfg, agentId }).catch(onError);
80
+ }
81
+ }, sessions.syncIntervalMinutes * 60_000);
82
+ this.#sessionSyncTimer.unref();
83
+ }
84
+ stopSessionSyncSchedule() {
85
+ if (this.#sessionSyncTimer)
86
+ clearInterval(this.#sessionSyncTimer);
87
+ this.#sessionSyncTimer = undefined;
88
+ }
71
89
  constructor(corpora, options = {}) {
72
90
  this.#corpora = corpora;
73
91
  this.#analysisExecutable = options.analysisExecutable;
@@ -186,6 +204,7 @@ export class QmdMemoryRuntime {
186
204
  await (await pending)?.close();
187
205
  }
188
206
  async closeAllMemorySearchManagers() {
207
+ this.stopSessionSyncSchedule();
189
208
  const managers = [...this.#managers.values()];
190
209
  this.#managers.clear();
191
210
  await Promise.all(managers.map(async (pending) => (await pending).close()));
@@ -224,6 +243,7 @@ export class QmdMemoryRuntime {
224
243
  agentId,
225
244
  agentName: resolveAgentIdentity(cfg, agentId)?.name?.trim() || agentId,
226
245
  chatTypes: sessionCorpus.chatTypes,
246
+ maxExpandedTokens: sessionCorpus.maxExpandedTokens,
227
247
  collection: sessionSource.collection,
228
248
  databasePath: join(resolveAgentDir(cfg, agentId), "openclaw-agent.sqlite"),
229
249
  manifestPath: join(stateDir, "sessions-manifest.json"),
@@ -16,6 +16,17 @@ export type SessionProjectionInput = SessionMetadata & {
16
16
  createdAt: number;
17
17
  }[];
18
18
  };
19
+ export type SessionContextSpans = {
20
+ message: {
21
+ start: number;
22
+ end: number;
23
+ };
24
+ turn: {
25
+ start: number;
26
+ end: number;
27
+ };
28
+ };
19
29
  export declare function projectSession(input: SessionProjectionInput): string | undefined;
30
+ export declare function sessionContextSpans(content: string, position: number): SessionContextSpans | undefined;
20
31
  export declare function sessionDocumentPath(metadata: SessionMetadata): string;
21
32
  export declare function resolveTimezone(configured?: string): string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ const MESSAGE_HEADING = /^## (User|Assistant) — .* — \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \S.*$/gmu;
2
3
  function record(value) {
3
4
  return value !== null && typeof value === "object" && !Array.isArray(value)
4
5
  ? value
@@ -79,6 +80,7 @@ function projectMessage(row, input) {
79
80
  if (!text)
80
81
  return undefined;
81
82
  return {
83
+ role,
82
84
  speaker: speaker.replace(/[\r\n]+/gu, " "),
83
85
  text,
84
86
  timestamp: timestamp(eventRecord.timestamp) ?? row.createdAt ?? timestamp(message.timestamp) ?? input.startedAt,
@@ -107,9 +109,36 @@ export function projectSession(input) {
107
109
  });
108
110
  if (messages.length === 0)
109
111
  return undefined;
110
- const transcript = messages.map((message) => `${formatTimestamp(message.timestamp, input.timezone)} — ${message.speaker}: ${message.text}`);
112
+ const transcript = messages.map((message) => `## ${message.role === "user" ? "User" : "Assistant"} — ${message.speaker} ` +
113
+ `${formatTimestamp(message.timestamp, input.timezone)}\n\n${message.text}`);
111
114
  return `# Transcript\n\n${transcript.join("\n\n")}\n`;
112
115
  }
116
+ export function sessionContextSpans(content, position) {
117
+ const markers = [...content.matchAll(MESSAGE_HEADING)].map((match) => ({
118
+ start: match.index,
119
+ role: match[1] === "User" ? "user" : "assistant",
120
+ }));
121
+ const containing = markers.findLastIndex((marker) => marker.start <= position);
122
+ if (containing < 0)
123
+ return undefined;
124
+ const message = {
125
+ start: markers[containing].start,
126
+ end: markers[containing + 1]?.start ?? content.length,
127
+ };
128
+ let turnStart = containing;
129
+ while (turnStart > 0 && markers[turnStart].role !== "user")
130
+ turnStart -= 1;
131
+ if (markers[turnStart].role !== "user")
132
+ turnStart = containing;
133
+ const nextUser = markers.findIndex((marker, index) => index > turnStart && marker.role === "user");
134
+ return {
135
+ message,
136
+ turn: {
137
+ start: markers[turnStart].start,
138
+ end: nextUser < 0 ? content.length : markers[nextUser].start,
139
+ },
140
+ };
141
+ }
113
142
  function hash(value) {
114
143
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
115
144
  }
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
  import { projectSession, sessionDocumentPath, } from "./session-projector.js";
7
7
  const MANIFEST_VERSION = 1;
8
- const PROJECTOR_VERSION = 2;
8
+ const PROJECTOR_VERSION = 3;
9
9
  const SUPPORTED_SCHEMA_VERSIONS = new Set([17, 18, 19]);
10
10
  const REQUIRED_COLUMNS = {
11
11
  schema_meta: ["meta_key", "role", "schema_version", "agent_id", "app_version"],
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.10",
4
+ "version": "0.3.12",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
- "activation": { "onStartup": false },
7
+ "activation": { "onStartup": true },
8
8
  "skills": ["./skills"],
9
9
  "contracts": {
10
10
  "tools": [
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "corpora": {
43
43
  "label": "Memory Corpora",
44
- "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace. Skill files use the isolated skills corpus."
44
+ "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace. Skill files use the isolated skills corpus. Session corpora can expand matching semantic chunks up to maxExpandedTokens."
45
45
  },
46
46
  "skillWhisperer.enabled": {
47
47
  "label": "Skill Whisperer",
@@ -94,11 +94,24 @@
94
94
  "properties": {
95
95
  "name": { "const": "sessions" },
96
96
  "kind": { "const": "sessions" },
97
+ "syncIntervalMinutes": {
98
+ "type": "integer",
99
+ "minimum": 0,
100
+ "maximum": 1440,
101
+ "default": 15,
102
+ "description": "Refresh sessions every N minutes while the Gateway runs; 0 disables automatic sync. First refresh is after one interval."
103
+ },
97
104
  "chatTypes": {
98
105
  "type": "array",
99
106
  "minItems": 1,
100
107
  "items": { "enum": ["channel", "group", "direct"] },
101
108
  "default": ["channel", "group"]
109
+ },
110
+ "maxExpandedTokens": {
111
+ "type": "integer",
112
+ "minimum": 1,
113
+ "maximum": 10000,
114
+ "default": 500
102
115
  }
103
116
  }
104
117
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "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"
36
36
  },
37
37
  "dependencies": {
38
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.3/unblocklabs-qmd-2.9.3.tgz",
38
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.4/unblocklabs-qmd-2.9.4.tgz",
39
39
  "chokidar": "5.0.0",
40
40
  "picomatch": "^4.0.5",
41
41
  "typebox": "1.3.6"
@@ -10,6 +10,20 @@ understanding of its world. A cluster shows similarity, not a complete timeline,
10
10
  truth, or consensus. Do not write from a cluster alone, and prefer no write over
11
11
  weak, duplicative, or easily looked-up knowledge.
12
12
 
13
+ ## Clusters and the knowledge corpus
14
+
15
+ Clustering and knowledge are separate. `memory_recluster` analyzes every
16
+ configured non-skill corpus, so a cluster may connect raw memory, sessions,
17
+ meeting notes, other source material, and previously maintained knowledge.
18
+
19
+ `knowledge` is not a special cluster type. It is the dedicated indexed corpus
20
+ for the agent's maintained synthesis, normally backed by
21
+ `knowledge/**/*.md`. Source corpora preserve what happened or was recorded;
22
+ the knowledge corpus preserves the agent's supported current understanding
23
+ when that understanding would be expensive to reconstruct. A cluster is only
24
+ an invitation to investigate, not evidence that something belongs in
25
+ knowledge.
26
+
13
27
  ## Investigate
14
28
 
15
29
  1. Call `memory_list_clusters`. If analysis is missing or stale, call
@@ -21,7 +35,7 @@ weak, duplicative, or easily looked-up knowledge.
21
35
  fallback when `eventTime` is unresolved.
22
36
  3. State the question the cluster raises: what may be repeated, contradictory,
23
37
  changing, or worth understanding?
24
- 4. Search existing knowledge with `memory_search`, using
38
+ 4. Search existing maintained knowledge with `memory_search`, using
25
39
  `corpora: ["knowledge"]`. If that corpus is not configured, report that and
26
40
  do not create an unindexed file.
27
41
  5. Investigate the evidence needed to answer the question. Follow important