@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 +30 -12
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +10 -1
- package/dist/src/manager.d.ts +6 -1
- package/dist/src/manager.js +40 -18
- package/dist/src/plugin.js +6 -0
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +21 -1
- package/dist/src/session-projector.d.ts +11 -0
- package/dist/src/session-projector.js +30 -1
- package/dist/src/session-sync.js +1 -1
- package/openclaw.plugin.json +16 -3
- package/package.json +2 -2
- package/skills/memory-curator/SKILL.md +15 -1
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
|
|
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.
|
|
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
|
|
248
|
-
manifest. The projected file modification time matches the session
|
|
249
|
-
for meaningful chronological cluster reads. Session results include
|
|
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.
|
|
252
|
-
|
|
253
|
-
`
|
|
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
|
|
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
|
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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[];
|
package/dist/src/config.js
CHANGED
|
@@ -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}]`);
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -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: {
|
package/dist/src/manager.js
CHANGED
|
@@ -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(
|
|
178
|
-
const before =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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();
|
package/dist/src/plugin.js
CHANGED
|
@@ -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,
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/runtime.js
CHANGED
|
@@ -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) =>
|
|
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
|
}
|
package/dist/src/session-sync.js
CHANGED
|
@@ -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 =
|
|
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"],
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.3.
|
|
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":
|
|
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.
|
|
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.
|
|
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
|