@unblocklabs/unblock-memory 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unblock Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # Unblock Memory
2
+
3
+ Workspace-native memory for OpenClaw, powered internally by `@unblocklabs/qmd`.
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 QMD vsearch without
6
+ a reranker.
7
+
8
+ Optional memory analysis uses those same stored vectors in the same SQLite
9
+ index. It does not re-embed memory, copy vectors, or create another database.
10
+
11
+ ## Installation
12
+
13
+ From npm:
14
+
15
+ ```bash
16
+ openclaw plugins install @unblocklabs/unblock-memory
17
+ ```
18
+
19
+ Or directly from GitHub:
20
+
21
+ ```bash
22
+ openclaw plugins install git:github.com/unblocklabs-ai/unblock-memory
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Select the plugin as the memory provider and list any exact Markdown files,
28
+ directories, or globs to index:
29
+
30
+ ```json5
31
+ {
32
+ plugins: {
33
+ slots: { memory: "unblock-memory" },
34
+ entries: {
35
+ "unblock-memory": {
36
+ config: {
37
+ paths: [
38
+ "MEMORY.md",
39
+ "USER.md",
40
+ "memory/**/*.md",
41
+ "/absolute/shared/**/*.md",
42
+ ],
43
+ analysis: {
44
+ executable: "/absolute/path/to/unblock-memory-analysis",
45
+ },
46
+ },
47
+ },
48
+ },
49
+ },
50
+ }
51
+ ```
52
+
53
+ 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.
57
+
58
+ Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
59
+ equivalent configured OpenClaw state directory). The first lookup builds the
60
+ index; Markdown filesystem changes queue a debounced, serialized background
61
+ refresh.
62
+
63
+ ## Memory analysis
64
+
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,
69
+ executable, shell command, or arbitrary arguments.
70
+
71
+ The analysis worker reads QMD's existing semantic vectors and writes only
72
+ derived results into three namespaced tables in that same `index.sqlite`:
73
+
74
+ - `memory_analysis_runs`
75
+ - `memory_analysis_clusters`
76
+ - `memory_analysis_memberships`
77
+
78
+ Unblock Memory exposes:
79
+
80
+ - `memory_list_clusters` to cheaply list current clusters and report whether the
81
+ retained analysis is stale
82
+ - `memory_recluster` to explicitly rebuild clusters when the list is missing or stale
83
+ - `memory_fetch_cluster` to return up to `topK` representative QMD chunks for a
84
+ short `clusterId` returned by `memory_list_clusters`
85
+
86
+ `memory_recluster` optionally accepts UMAP controls (`method`, components,
87
+ neighbors, and minimum distance), HDBSCAN controls (minimum cluster size,
88
+ minimum samples, selection method and epsilon, and single-cluster behavior),
89
+ and a deterministic seed. Omitting them uses the worker's defaults.
90
+
91
+ Cluster reads return at most 50 members. Member excerpts are capped at 2 KB
92
+ each and 12 KB across a response; source aliases are capped at five per member
93
+ and 50 across a response.
94
+
95
+ If indexing changes content or vectors, the previous derived analysis is kept
96
+ and marked stale. Cluster reads include the analysis timestamp, stale timestamp,
97
+ and a hint to call `memory_recluster`; unavailable chunks reduce `availableSize`
98
+ without copying canonical text into analysis tables. A no-op sync stays fresh.
99
+ A failed rebuild leaves the stale result intact, while a successful rebuild
100
+ atomically replaces it. Analysis is never scheduled automatically. If the worker
101
+ is absent or fails, `memory_search` and `memory_get` continue to work.
102
+
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.
@@ -0,0 +1,14 @@
1
+ declare const _default: Omit<{
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ kind?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["kind"];
6
+ configSchema?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema | (() => import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema);
7
+ reload?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["reload"];
8
+ nodeHostCommands?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["nodeHostCommands"];
9
+ securityAuditCollectors?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["securityAuditCollectors"];
10
+ register: NonNullable<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["register"]>;
11
+ }, "configSchema"> & {
12
+ configSchema: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema;
13
+ };
14
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { registerUnblockMemory } from "./src/plugin.js";
3
+ export default definePluginEntry({
4
+ id: "unblock-memory",
5
+ name: "Unblock Memory",
6
+ description: "Workspace-native memory search powered by QMD",
7
+ kind: "memory",
8
+ register: registerUnblockMemory,
9
+ });
@@ -0,0 +1,93 @@
1
+ import type { QMDStore } from "@unblocklabs/qmd";
2
+ export declare const DEFAULT_CLUSTER_LIMIT = 20;
3
+ export declare const MAX_CLUSTER_LIMIT = 50;
4
+ export declare const DEFAULT_MEMBER_LIMIT = 20;
5
+ export declare const MAX_MEMBER_LIMIT = 50;
6
+ type AnalysisDatabase = QMDStore["internal"]["db"];
7
+ export type MemoryReclusterOptions = {
8
+ space?: {
9
+ method?: "umap" | "none";
10
+ nComponents?: number;
11
+ nNeighbors?: number;
12
+ minDist?: number;
13
+ };
14
+ hdbscan?: {
15
+ minClusterSize?: number;
16
+ minSamples?: number;
17
+ clusterSelectionMethod?: "eom" | "leaf";
18
+ clusterSelectionEpsilon?: number;
19
+ allowSingleCluster?: boolean;
20
+ };
21
+ seed?: number;
22
+ };
23
+ export type AnalysisRunner = (params: {
24
+ executable: string;
25
+ dbPath: string;
26
+ options?: MemoryReclusterOptions;
27
+ signal?: AbortSignal;
28
+ }) => Promise<void>;
29
+ export type MemoryAnalysisMember = {
30
+ hash: string;
31
+ seq: number;
32
+ probability: number;
33
+ outlierScore: number;
34
+ x: number;
35
+ y: number;
36
+ representativeRank: number | null;
37
+ text: string;
38
+ sourcePaths: string[];
39
+ };
40
+ export type MemoryAnalysisSummary = {
41
+ status: "ok";
42
+ runId: string;
43
+ createdAt: string;
44
+ completedAt: string;
45
+ inputDigest: string;
46
+ model: string;
47
+ embeddingFingerprint: string;
48
+ dimensions: number;
49
+ clusters: number;
50
+ members: number;
51
+ noise: number;
52
+ stale: boolean;
53
+ staleSince: string | null;
54
+ };
55
+ export type MemoryClusterSummary = {
56
+ clusterId: string;
57
+ size: number;
58
+ availableSize: number;
59
+ meanProbability: number;
60
+ preview?: Pick<MemoryAnalysisMember, "hash" | "seq" | "probability" | "text" | "sourcePaths">;
61
+ };
62
+ type AnalysisReadMetadata = {
63
+ stale: boolean;
64
+ staleSince: string | null;
65
+ analyzedAt: string | null;
66
+ hint?: string;
67
+ };
68
+ export type MemoryClusterList = AnalysisReadMetadata & {
69
+ status: "ok" | "not_analyzed";
70
+ runId?: string;
71
+ clusters: MemoryClusterSummary[];
72
+ noise: MemoryClusterSummary | null;
73
+ };
74
+ export type MemoryClusterDetail = AnalysisReadMetadata & {
75
+ status: "ok" | "not_found" | "not_analyzed";
76
+ runId?: string;
77
+ cluster?: Omit<MemoryClusterSummary, "preview">;
78
+ members?: MemoryAnalysisMember[];
79
+ };
80
+ export declare function ensureMemoryAnalysisSchema(db: AnalysisDatabase): void;
81
+ export declare function markMemoryAnalysisStale(db: AnalysisDatabase): void;
82
+ export declare function clusterReference(runId: string, clusterId: number): string;
83
+ export declare function runAnalysisWorker(params: {
84
+ executable: string;
85
+ dbPath: string;
86
+ options?: MemoryReclusterOptions;
87
+ signal?: AbortSignal;
88
+ }): Promise<void>;
89
+ export declare function latestAnalysisRunId(db: AnalysisDatabase): string | undefined;
90
+ export declare function readAnalysisSummary(db: AnalysisDatabase): MemoryAnalysisSummary | undefined;
91
+ export declare function readClusters(db: AnalysisDatabase, requestedLimit?: number): MemoryClusterList;
92
+ export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number): MemoryClusterDetail;
93
+ export {};
@@ -0,0 +1,406 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ export const DEFAULT_CLUSTER_LIMIT = 20;
4
+ export const MAX_CLUSTER_LIMIT = 50;
5
+ export const DEFAULT_MEMBER_LIMIT = 20;
6
+ export const MAX_MEMBER_LIMIT = 50;
7
+ const MAX_EXCERPT_BYTES = 2_000;
8
+ const MAX_TOTAL_EXCERPT_BYTES = 12_000;
9
+ const MAX_ALIASES_PER_MEMBER = 5;
10
+ const MAX_TOTAL_ALIASES = 50;
11
+ export function ensureMemoryAnalysisSchema(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS memory_analysis_runs (
14
+ id TEXT PRIMARY KEY,
15
+ created_at TEXT NOT NULL,
16
+ completed_at TEXT,
17
+ input_digest TEXT NOT NULL,
18
+ model TEXT NOT NULL,
19
+ embedding_fingerprint TEXT NOT NULL,
20
+ dimensions INTEGER NOT NULL,
21
+ params_json TEXT NOT NULL,
22
+ stale_at TEXT
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS memory_analysis_clusters (
26
+ run_id TEXT NOT NULL,
27
+ cluster_id INTEGER NOT NULL,
28
+ size INTEGER NOT NULL,
29
+ mean_probability REAL NOT NULL,
30
+ PRIMARY KEY (run_id, cluster_id),
31
+ FOREIGN KEY (run_id) REFERENCES memory_analysis_runs(id) ON DELETE CASCADE
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS memory_analysis_memberships (
35
+ run_id TEXT NOT NULL,
36
+ hash TEXT NOT NULL,
37
+ seq INTEGER NOT NULL,
38
+ cluster_id INTEGER NOT NULL,
39
+ probability REAL NOT NULL,
40
+ outlier_score REAL NOT NULL,
41
+ x REAL NOT NULL,
42
+ y REAL NOT NULL,
43
+ representative_rank INTEGER,
44
+ PRIMARY KEY (run_id, hash, seq),
45
+ FOREIGN KEY (run_id) REFERENCES memory_analysis_runs(id) ON DELETE CASCADE
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_memory_analysis_memberships_cluster
49
+ ON memory_analysis_memberships(run_id, cluster_id, representative_rank);
50
+ `);
51
+ }
52
+ export function markMemoryAnalysisStale(db) {
53
+ db.prepare(`
54
+ UPDATE memory_analysis_runs
55
+ SET stale_at = COALESCE(stale_at, CURRENT_TIMESTAMP)
56
+ WHERE id = (
57
+ SELECT id
58
+ FROM memory_analysis_runs
59
+ WHERE completed_at IS NOT NULL
60
+ ORDER BY completed_at DESC, created_at DESC, id DESC
61
+ LIMIT 1
62
+ )
63
+ `).run();
64
+ }
65
+ export function clusterReference(runId, clusterId) {
66
+ return createHash("sha256").update(`${runId}\0${clusterId}`).digest("hex").slice(0, 10);
67
+ }
68
+ export function runAnalysisWorker(params) {
69
+ return new Promise((resolve, reject) => {
70
+ params.signal?.throwIfAborted();
71
+ const args = ["--db", params.dbPath];
72
+ if (params.options && Object.keys(params.options).length > 0) {
73
+ args.push("--config-json", JSON.stringify(params.options));
74
+ }
75
+ const child = spawn(params.executable, args, {
76
+ shell: false,
77
+ stdio: ["ignore", "ignore", "pipe"],
78
+ });
79
+ const stderr = [];
80
+ let stderrBytes = 0;
81
+ const maxErrorBytes = 16_384;
82
+ child.stderr.on("data", (chunk) => {
83
+ if (stderrBytes >= maxErrorBytes)
84
+ return;
85
+ const remaining = maxErrorBytes - stderrBytes;
86
+ stderr.push(chunk.subarray(0, remaining));
87
+ stderrBytes += Math.min(chunk.length, remaining);
88
+ });
89
+ let settled = false;
90
+ let abortError;
91
+ const cleanup = () => params.signal?.removeEventListener("abort", onAbort);
92
+ const finish = (error) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ cleanup();
97
+ error ? reject(error) : resolve();
98
+ };
99
+ const onAbort = () => {
100
+ if (abortError)
101
+ return;
102
+ abortError = params.signal?.reason instanceof Error
103
+ ? params.signal.reason
104
+ : new Error("Memory reclustering aborted");
105
+ child.kill("SIGTERM");
106
+ };
107
+ params.signal?.addEventListener("abort", onAbort, { once: true });
108
+ if (params.signal?.aborted)
109
+ onAbort();
110
+ child.on("error", (error) => finish(abortError ?? error));
111
+ child.on("close", (code, signal) => {
112
+ if (abortError) {
113
+ finish(abortError);
114
+ return;
115
+ }
116
+ if (code === 0) {
117
+ finish();
118
+ return;
119
+ }
120
+ const detail = Buffer.concat(stderr).toString("utf8").trim();
121
+ finish(new Error(`Memory analysis worker ${signal ? `was terminated by ${signal}` : `exited with code ${code ?? "unknown"}`}${detail ? `: ${detail}` : ""}`));
122
+ });
123
+ });
124
+ }
125
+ function latestRun(db) {
126
+ return db.prepare(`
127
+ SELECT id, created_at, completed_at, input_digest, model, embedding_fingerprint, dimensions, stale_at
128
+ FROM memory_analysis_runs
129
+ WHERE completed_at IS NOT NULL
130
+ ORDER BY completed_at DESC, created_at DESC, id DESC
131
+ LIMIT 1
132
+ `).get();
133
+ }
134
+ export function latestAnalysisRunId(db) {
135
+ return latestRun(db)?.id;
136
+ }
137
+ function count(db, sql, runId) {
138
+ return db.prepare(sql).get(runId)?.count ?? 0;
139
+ }
140
+ export function readAnalysisSummary(db) {
141
+ const run = latestRun(db);
142
+ if (!run)
143
+ return undefined;
144
+ const clusters = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_clusters WHERE run_id = ?", run.id);
145
+ const members = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ?", run.id);
146
+ const expectedNonNoise = count(db, "SELECT COALESCE(SUM(size), 0) AS count FROM memory_analysis_clusters WHERE run_id = ?", run.id);
147
+ const nonNoise = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ? AND cluster_id <> -1", run.id);
148
+ const noise = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ? AND cluster_id = -1", run.id);
149
+ const unassigned = count(db, `
150
+ SELECT COUNT(*) AS count
151
+ FROM memory_analysis_memberships m
152
+ LEFT JOIN memory_analysis_clusters c
153
+ ON c.run_id = m.run_id AND c.cluster_id = m.cluster_id
154
+ WHERE m.run_id = ? AND m.cluster_id <> -1 AND c.cluster_id IS NULL
155
+ `, run.id);
156
+ if (nonNoise !== expectedNonNoise || members !== nonNoise + noise || unassigned > 0)
157
+ return undefined;
158
+ return {
159
+ status: "ok",
160
+ runId: run.id,
161
+ createdAt: run.created_at,
162
+ completedAt: run.completed_at,
163
+ inputDigest: run.input_digest,
164
+ model: run.model,
165
+ embeddingFingerprint: run.embedding_fingerprint,
166
+ dimensions: run.dimensions,
167
+ clusters,
168
+ members,
169
+ noise,
170
+ stale: run.stale_at !== null,
171
+ staleSince: run.stale_at,
172
+ };
173
+ }
174
+ function sourcePaths(db, hash, limit) {
175
+ if (limit <= 0)
176
+ return [];
177
+ return db.prepare(`
178
+ SELECT collection, path
179
+ FROM documents
180
+ WHERE hash = ? AND active = 1
181
+ ORDER BY collection, path
182
+ LIMIT ?
183
+ `).all(hash, limit).map((row) => `qmd://${row.collection}/${row.path}`);
184
+ }
185
+ function byteSlice(text, maxBytes) {
186
+ const bytes = Buffer.from(text);
187
+ if (bytes.length <= maxBytes)
188
+ return text;
189
+ if (maxBytes <= 3)
190
+ return "";
191
+ return bytes.subarray(0, maxBytes - 3).toString("utf8").replace(/\uFFFD$/u, "") + "…";
192
+ }
193
+ function members(db, runId, clusterId, limit, maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES) {
194
+ const noiseOrder = clusterId === -1
195
+ ? "m.outlier_score DESC, m.hash, m.seq"
196
+ : `CASE WHEN m.representative_rank IS NULL THEN 1 ELSE 0 END,
197
+ m.representative_rank,
198
+ m.probability DESC,
199
+ m.outlier_score,
200
+ m.hash,
201
+ m.seq`;
202
+ const rows = db.prepare(`
203
+ SELECT
204
+ m.hash, m.seq, m.probability, m.outlier_score, m.x, m.y,
205
+ m.representative_rank, cv.pos, cv.chunk_len, c.doc
206
+ FROM memory_analysis_memberships m
207
+ JOIN content_vectors cv ON cv.hash = m.hash AND cv.seq = m.seq
208
+ JOIN content c ON c.hash = m.hash
209
+ WHERE m.run_id = ? AND m.cluster_id = ?
210
+ AND EXISTS (
211
+ SELECT 1
212
+ FROM documents d
213
+ WHERE d.hash = m.hash AND d.active = 1
214
+ )
215
+ ORDER BY ${noiseOrder}
216
+ LIMIT ?
217
+ `).all(runId, clusterId, limit);
218
+ let remaining = maxTotalBytes;
219
+ let remainingAliases = maxTotalAliases;
220
+ return rows.map((row) => {
221
+ const text = remaining <= 0
222
+ ? ""
223
+ : byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), Math.min(maxExcerptBytes, remaining));
224
+ remaining -= Buffer.byteLength(text);
225
+ const aliases = sourcePaths(db, row.hash, Math.min(MAX_ALIASES_PER_MEMBER, remainingAliases));
226
+ remainingAliases -= aliases.length;
227
+ return {
228
+ hash: row.hash,
229
+ seq: row.seq,
230
+ probability: row.probability,
231
+ outlierScore: row.outlier_score,
232
+ x: row.x,
233
+ y: row.y,
234
+ representativeRank: row.representative_rank,
235
+ text,
236
+ sourcePaths: aliases,
237
+ };
238
+ });
239
+ }
240
+ function availableSize(db, runId, clusterId) {
241
+ return db.prepare(`
242
+ SELECT COUNT(*) AS count
243
+ FROM memory_analysis_memberships m
244
+ JOIN content_vectors cv ON cv.hash = m.hash AND cv.seq = m.seq
245
+ JOIN content c ON c.hash = m.hash
246
+ WHERE m.run_id = ? AND m.cluster_id = ?
247
+ AND EXISTS (
248
+ SELECT 1
249
+ FROM documents d
250
+ WHERE d.hash = m.hash AND d.active = 1
251
+ )
252
+ `).get(runId, clusterId)?.count ?? 0;
253
+ }
254
+ function readMetadata(run) {
255
+ if (!run) {
256
+ return {
257
+ stale: true,
258
+ staleSince: null,
259
+ analyzedAt: null,
260
+ hint: "No memory analysis exists. Call memory_recluster, then memory_list_clusters.",
261
+ };
262
+ }
263
+ if (run.stale_at) {
264
+ return {
265
+ stale: true,
266
+ staleSince: run.stale_at,
267
+ analyzedAt: run.completed_at,
268
+ hint: "Memory changed after this analysis. Call memory_recluster to refresh it.",
269
+ };
270
+ }
271
+ return { stale: false, staleSince: null, analyzedAt: run.completed_at };
272
+ }
273
+ function toSummary(db, run, row, includePreview, previewBytes = 600, aliasLimit = MAX_TOTAL_ALIASES) {
274
+ const preview = includePreview
275
+ ? members(db, run.id, row.cluster_id, 1, previewBytes, previewBytes, aliasLimit)[0]
276
+ : undefined;
277
+ return {
278
+ clusterId: clusterReference(run.id, row.cluster_id),
279
+ size: row.size,
280
+ availableSize: row.available_size,
281
+ meanProbability: row.mean_probability,
282
+ ...(preview ? {
283
+ preview: {
284
+ hash: preview.hash,
285
+ seq: preview.seq,
286
+ probability: preview.probability,
287
+ text: preview.text,
288
+ sourcePaths: preview.sourcePaths,
289
+ },
290
+ } : {}),
291
+ };
292
+ }
293
+ function noiseRow(db, runId) {
294
+ return db.prepare(`
295
+ SELECT
296
+ -1 AS cluster_id,
297
+ COUNT(*) AS size,
298
+ COUNT(CASE WHEN cv.hash IS NOT NULL AND c.hash IS NOT NULL AND EXISTS (
299
+ SELECT 1
300
+ FROM documents d
301
+ WHERE d.hash = m.hash AND d.active = 1
302
+ ) THEN 1 END) AS available_size,
303
+ COALESCE(AVG(m.probability), 0) AS mean_probability
304
+ FROM memory_analysis_memberships m
305
+ LEFT JOIN content_vectors cv ON cv.hash = m.hash AND cv.seq = m.seq
306
+ LEFT JOIN content c ON c.hash = m.hash
307
+ WHERE m.run_id = ? AND m.cluster_id = -1
308
+ HAVING COUNT(*) > 0
309
+ `).get(runId);
310
+ }
311
+ export function readClusters(db, requestedLimit = DEFAULT_CLUSTER_LIMIT) {
312
+ const run = latestRun(db);
313
+ if (!run || !readAnalysisSummary(db)) {
314
+ return { status: "not_analyzed", ...readMetadata(), clusters: [], noise: null };
315
+ }
316
+ const limit = Math.max(1, Math.min(MAX_CLUSTER_LIMIT, Math.floor(requestedLimit)));
317
+ const rows = db.prepare(`
318
+ SELECT
319
+ c.cluster_id,
320
+ c.size,
321
+ COUNT(CASE WHEN cv.hash IS NOT NULL AND content_row.hash IS NOT NULL AND EXISTS (
322
+ SELECT 1
323
+ FROM documents d
324
+ WHERE d.hash = m.hash AND d.active = 1
325
+ ) THEN 1 END) AS available_size,
326
+ c.mean_probability
327
+ FROM memory_analysis_clusters c
328
+ LEFT JOIN memory_analysis_memberships m
329
+ ON m.run_id = c.run_id AND m.cluster_id = c.cluster_id
330
+ LEFT JOIN content_vectors cv ON cv.hash = m.hash AND cv.seq = m.seq
331
+ LEFT JOIN content content_row ON content_row.hash = m.hash
332
+ WHERE c.run_id = ?
333
+ GROUP BY c.cluster_id, c.size, c.mean_probability
334
+ ORDER BY c.size DESC, c.cluster_id
335
+ LIMIT ?
336
+ `).all(run.id, limit);
337
+ let remainingBytes = MAX_TOTAL_EXCERPT_BYTES;
338
+ let remainingAliases = MAX_TOTAL_ALIASES;
339
+ const clusters = rows.map((row) => {
340
+ const previewBytes = Math.min(600, remainingBytes);
341
+ const summary = toSummary(db, run, row, previewBytes > 0, previewBytes, remainingAliases);
342
+ remainingBytes -= Buffer.byteLength(summary.preview?.text ?? "");
343
+ remainingAliases -= summary.preview?.sourcePaths.length ?? 0;
344
+ return summary;
345
+ });
346
+ const noise = noiseRow(db, run.id);
347
+ return {
348
+ status: "ok",
349
+ runId: run.id,
350
+ ...readMetadata(run),
351
+ clusters,
352
+ noise: noise ? toSummary(db, run, noise, false) : null,
353
+ };
354
+ }
355
+ function resolveClusterId(db, runId, reference) {
356
+ const clusterIds = db.prepare(`
357
+ SELECT cluster_id
358
+ FROM memory_analysis_clusters
359
+ WHERE run_id = ?
360
+ UNION ALL
361
+ SELECT -1
362
+ WHERE EXISTS (
363
+ SELECT 1 FROM memory_analysis_memberships WHERE run_id = ? AND cluster_id = -1
364
+ )
365
+ `).all(runId, runId);
366
+ return clusterIds.find((row) => clusterReference(runId, row.cluster_id) === reference)?.cluster_id;
367
+ }
368
+ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEMBER_LIMIT) {
369
+ const run = latestRun(db);
370
+ if (!run || !readAnalysisSummary(db)) {
371
+ return { status: "not_analyzed", ...readMetadata() };
372
+ }
373
+ const metadata = readMetadata(run);
374
+ const clusterId = resolveClusterId(db, run.id, clusterReferenceId);
375
+ if (clusterId === undefined) {
376
+ return {
377
+ status: "not_found",
378
+ runId: run.id,
379
+ ...metadata,
380
+ hint: "Cluster IDs change after reclustering. Call memory_list_clusters and use a current clusterId.",
381
+ };
382
+ }
383
+ const row = clusterId === -1
384
+ ? noiseRow(db, run.id)
385
+ : db.prepare(`
386
+ SELECT cluster_id, size, mean_probability, 0 AS available_size
387
+ FROM memory_analysis_clusters
388
+ WHERE run_id = ? AND cluster_id = ?
389
+ `).get(run.id, clusterId);
390
+ if (!row) {
391
+ return { status: "not_found", runId: run.id, ...metadata };
392
+ }
393
+ const limit = Math.max(1, Math.min(MAX_MEMBER_LIMIT, Math.floor(requestedLimit)));
394
+ return {
395
+ status: "ok",
396
+ runId: run.id,
397
+ ...metadata,
398
+ cluster: {
399
+ clusterId: clusterReferenceId,
400
+ size: row.size,
401
+ availableSize: availableSize(db, run.id, clusterId),
402
+ meanProbability: row.mean_probability,
403
+ },
404
+ members: members(db, run.id, clusterId, limit),
405
+ };
406
+ }
@@ -0,0 +1,8 @@
1
+ export declare const DEFAULT_PATHS: readonly ["MEMORY.md", "USER.md", "memory/**/*.md"];
2
+ export type UnblockMemoryConfig = {
3
+ paths: readonly string[];
4
+ analysis: {
5
+ executable?: string;
6
+ };
7
+ };
8
+ export declare function resolveConfig(value: unknown): UnblockMemoryConfig;
@@ -0,0 +1,27 @@
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: {} };
6
+ }
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");
12
+ }
13
+ paths = config.paths.map((entry) => entry.trim());
14
+ }
15
+ if (config.analysis === undefined)
16
+ return { paths, analysis: {} };
17
+ if (!config.analysis || typeof config.analysis !== "object") {
18
+ throw new Error("unblock-memory analysis must be an object");
19
+ }
20
+ const configured = config.analysis.executable;
21
+ if (configured === undefined)
22
+ return { paths, analysis: {} };
23
+ if (typeof configured !== "string" || !configured.trim() || !isAbsolute(configured.trim())) {
24
+ throw new Error("unblock-memory analysis.executable must be an absolute non-empty path");
25
+ }
26
+ return { paths, analysis: { executable: configured.trim() } };
27
+ }