@unblocklabs/unblock-memory 0.3.10 → 0.3.11

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,17 +238,22 @@ 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
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
251
257
  participate in the same search and clustering index as file memory. This phase
252
258
  does not sync sessions at startup or on a schedule; refreshes are manual through
253
259
  `memory_sync_sessions`.
@@ -262,7 +268,7 @@ Markdown filesystem changes queue a debounced, serialized background refresh.
262
268
 
263
269
  Analysis is opt-in. Core indexing, `memory_search`, and `memory_get` need only
264
270
  Unblock Memory and its automatically installed QMD dependency. To enable
265
- clustering, install the
271
+ clustering, install the public
266
272
  [`unblock-cluster`](https://github.com/unblocklabs-ai/unblock-cluster) worker once
267
273
  on the same host:
268
274
 
@@ -270,7 +276,7 @@ on the same host:
270
276
  git clone https://github.com/unblocklabs-ai/unblock-cluster.git
271
277
  cd unblock-cluster
272
278
  python3 -m venv .venv
273
- .venv/bin/python -m pip install -r requirements.txt
279
+ .venv/bin/python -m pip install -r requirements-analysis.txt
274
280
  ```
275
281
 
276
282
  Set `analysis.executable` to the absolute path of
@@ -286,11 +292,12 @@ analyzed and `memory_recluster` reports that analysis is unavailable. Ordinary
286
292
  memory search and reads continue to work.
287
293
 
288
294
  The analysis worker reads QMD's existing semantic vectors and writes only
289
- derived results into three namespaced tables in that same `index.sqlite`:
295
+ derived results into four namespaced tables in that same `index.sqlite`:
290
296
 
291
297
  - `memory_analysis_runs`
292
298
  - `memory_analysis_clusters`
293
299
  - `memory_analysis_memberships`
300
+ - `memory_analysis_duplicate_occurrences`
294
301
 
295
302
  Unblock Memory exposes:
296
303
 
@@ -14,6 +14,7 @@ type SessionCorpusConfig = {
14
14
  name: "sessions";
15
15
  kind: "sessions";
16
16
  chatTypes: readonly ChatType[];
17
+ maxExpandedTokens: number;
17
18
  };
18
19
  export type CorpusConfig = FileCorpusConfig | SkillCorpusConfig | SessionCorpusConfig;
19
20
  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"], `corpora[${index}]`);
63
65
  if (name !== "sessions") {
64
66
  throw new Error('unblock-memory session corpus must be named "sessions"');
65
67
  }
@@ -73,6 +75,7 @@ function resolveCorpora(value) {
73
75
  name: "sessions",
74
76
  kind: "sessions",
75
77
  chatTypes: [...new Set(chatTypes)],
78
+ maxExpandedTokens: positiveInteger(corpus.maxExpandedTokens, DEFAULT_SESSION_MAX_EXPANDED_TOKENS, "corpus sessions maxExpandedTokens", MAX_SESSION_MAX_EXPANDED_TOKENS),
76
79
  };
77
80
  }
78
81
  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();
@@ -224,6 +224,7 @@ export class QmdMemoryRuntime {
224
224
  agentId,
225
225
  agentName: resolveAgentIdentity(cfg, agentId)?.name?.trim() || agentId,
226
226
  chatTypes: sessionCorpus.chatTypes,
227
+ maxExpandedTokens: sessionCorpus.maxExpandedTokens,
227
228
  collection: sessionSource.collection,
228
229
  databasePath: join(resolveAgentDir(cfg, agentId), "openclaw-agent.sqlite"),
229
230
  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,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.10",
4
+ "version": "0.3.11",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
@@ -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",
@@ -99,6 +99,12 @@
99
99
  "minItems": 1,
100
100
  "items": { "enum": ["channel", "group", "direct"] },
101
101
  "default": ["channel", "group"]
102
+ },
103
+ "maxExpandedTokens": {
104
+ "type": "integer",
105
+ "minimum": 1,
106
+ "maximum": 10000,
107
+ "default": 500
102
108
  }
103
109
  }
104
110
  },
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.11",
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