@unblocklabs/unblock-memory 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/src/agent-transcript.d.ts +36 -5
- package/dist/src/agent-transcript.js +101 -11
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/diagnostics.d.ts +5 -1
- package/dist/src/diagnostics.js +3 -0
- package/dist/src/inside-out-sources.js +21 -12
- package/dist/src/manager.d.ts +3 -2
- package/dist/src/manager.js +9 -3
- package/dist/src/memory-search.d.ts +31 -0
- package/dist/src/memory-search.js +61 -0
- package/dist/src/memory-whisperer.d.ts +2 -6
- package/dist/src/memory-whisperer.js +39 -73
- package/dist/src/plugin.js +72 -34
- package/dist/src/response-episodes.d.ts +3 -3
- package/dist/src/response-episodes.js +52 -13
- package/dist/src/response-text.js +13 -0
- package/dist/src/retrieval-telemetry.d.ts +3 -2
- package/dist/src/review-tools.js +1 -1
- package/dist/src/session-sync.d.ts +1 -1
- package/dist/src/session-sync.js +26 -14
- package/dist/src/training-candidates.d.ts +2 -2
- package/dist/src/training-candidates.js +2 -2
- package/dist/src/training-input.d.ts +4 -0
- package/dist/src/training-input.js +8 -6
- package/dist/src/training-queries.d.ts +2 -0
- package/dist/src/training.d.ts +2 -0
- package/dist/src/training.js +6 -1
- package/docs/configuration.md +3 -2
- package/docs/memory-training.md +3 -1
- package/docs/response-audit.md +2 -2
- package/docs/retrieval.md +59 -59
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
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 `memory_search` and `memory_get`.
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Recall combines BM25 and vector candidates, then independently ranks complete
|
|
6
|
+
passages with TypeSafe. The agent supplies both queries; no query generation or
|
|
7
|
+
"do we need memory?" gate runs for explicit search. A TypeSafe key is required.
|
|
7
8
|
|
|
8
9
|
## Start here
|
|
9
10
|
|
|
@@ -38,7 +39,7 @@ Optional installation from source:
|
|
|
38
39
|
|
|
39
40
|
| Capability | What it does | Default / prerequisite |
|
|
40
41
|
| --- | --- | --- |
|
|
41
|
-
| [Search and reads](docs/retrieval.md#search-and-read) |
|
|
42
|
+
| [Search and reads](docs/retrieval.md#search-and-read) | BM25 + vector recall with TypeSafe ranking, then exact indexed reads | Search requires TypeSafe; reads stay local; files only unless more corpora are configured |
|
|
42
43
|
| [Session indexing](docs/retrieval.md#sessions) | Makes past user/assistant exchanges searchable | Opt-in corpus; channel/group by default, DMs explicitly included |
|
|
43
44
|
| [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill; never invokes it | Off; explicit skills corpus; TypeSafe optional |
|
|
44
45
|
| [Memory Whisperer](docs/retrieval.md#memory-whisperer) | Injects up to two useful historical excerpts; query generation via resident API | Off; approved corpora + TypeSafe + query API credential |
|
|
@@ -52,10 +53,9 @@ Optional installation from source:
|
|
|
52
53
|
| [Session memory extraction](docs/extraction.md) | Luna proposes lasting facts; Jev verifies them; optional QMD search | Off; approved session types + TypeSafe + host LLM permission; shadow-only by default |
|
|
53
54
|
| [Compaction memory flush](docs/configuration.md#compaction-memory-writes) | Supplies the host an append-only daily-memory write plan | Offered unless the host's memory-flush setting is false; separate from whisperers |
|
|
54
55
|
|
|
55
|
-
**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
[search modes and the xsearch migration](docs/retrieval.md#qmd-search-modes).
|
|
56
|
+
**One agent search pipeline:** `memory_search` and Memory Whisperer share
|
|
57
|
+
candidate discovery, passage rendering, deduplication and TypeSafe ranking.
|
|
58
|
+
Whisperer additionally generates queries and applies its automatic recall/delivery policy.
|
|
59
59
|
|
|
60
60
|
## Configuration and operating guides
|
|
61
61
|
|
|
@@ -73,9 +73,9 @@ They have separate configuration/index boundaries. See
|
|
|
73
73
|
- [Session extraction](docs/extraction.md): incremental/backfill runs, configurable
|
|
74
74
|
acceptance thresholds, shadow evaluation, and publishing lasting facts to search.
|
|
75
75
|
|
|
76
|
-
The shared TypeSafe integration defaults on
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
The shared TypeSafe integration defaults on. Ordinary search requires a key;
|
|
77
|
+
other TypeSafe features remain opt-in. People Whisperer's injection stays local;
|
|
78
|
+
Skill Whisperer has a local fallback.
|
|
79
79
|
[Provider gates and failure behavior](docs/configuration.md#feature-gates-and-fallbacks)
|
|
80
80
|
differ by feature.
|
|
81
81
|
|
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
-
/**
|
|
2
|
+
/** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
|
|
3
3
|
export declare function agentTranscriptSchemaVersion(db: DatabaseSync, errorMessage?: string): number;
|
|
4
4
|
export declare function assertAgentTranscriptIdentity(db: DatabaseSync, agentId: string, version: number, errorMessage?: string): void;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
type AgentTranscriptRow = {
|
|
6
|
+
seq: number;
|
|
7
|
+
eventJson: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
};
|
|
10
|
+
type TranscriptSnapshot = {
|
|
11
|
+
kind: "ready";
|
|
12
|
+
rows: AgentTranscriptRow[];
|
|
13
|
+
} | {
|
|
14
|
+
kind: "cold";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "oversized";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "unreadable";
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* One read-only storage boundary for full-fidelity active transcripts. The public
|
|
22
|
+
* SDK's full reader may restore cold archives; its read-only catalog truncates and
|
|
23
|
+
* redacts content. Neither currently provides the contract these consumers need.
|
|
24
|
+
* Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
|
|
25
|
+
*/
|
|
26
|
+
export declare class AgentTranscriptReader {
|
|
27
|
+
#private;
|
|
28
|
+
readonly db: DatabaseSync;
|
|
29
|
+
constructor(db: DatabaseSync, agentId: string, errorMessage?: string);
|
|
30
|
+
/** Include storage availability in incremental fingerprints, not just hot row counts. */
|
|
31
|
+
coldSql(sessionIdExpression: string): string;
|
|
32
|
+
/** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
|
|
33
|
+
read(sessionId: string, limits?: {
|
|
34
|
+
maxEvents?: number;
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
maxSeq?: number;
|
|
37
|
+
}): TranscriptSnapshot;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
2
|
+
/** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
|
|
2
3
|
export function agentTranscriptSchemaVersion(db, errorMessage) {
|
|
3
4
|
const version = db.prepare("PRAGMA user_version").get()?.user_version;
|
|
4
|
-
if (typeof version !== "number" || !
|
|
5
|
-
throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected
|
|
5
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 17) {
|
|
6
|
+
throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected 17 or newer, " +
|
|
6
7
|
`found ${String(version ?? "unknown")}`);
|
|
7
8
|
}
|
|
8
9
|
return version;
|
|
@@ -18,12 +19,101 @@ export function assertAgentTranscriptIdentity(db, agentId, version, errorMessage
|
|
|
18
19
|
fail(`OpenClaw agent database belongs to ${String(meta?.agentId)}, not ${agentId}`);
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
assertAgentTranscriptIdentity(db, agentId, agentTranscriptSchemaVersion(db, errorMessage), errorMessage);
|
|
23
|
-
}
|
|
24
|
-
export const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
|
|
22
|
+
const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
|
|
25
23
|
JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq`;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const MAX_COMPRESSED_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
26
|
+
/**
|
|
27
|
+
* One read-only storage boundary for full-fidelity active transcripts. The public
|
|
28
|
+
* SDK's full reader may restore cold archives; its read-only catalog truncates and
|
|
29
|
+
* redacts content. Neither currently provides the contract these consumers need.
|
|
30
|
+
* Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
|
|
31
|
+
*/
|
|
32
|
+
export class AgentTranscriptReader {
|
|
33
|
+
db;
|
|
34
|
+
#schemaVersion;
|
|
35
|
+
#compressed;
|
|
36
|
+
#cold;
|
|
37
|
+
#utf8Storage;
|
|
38
|
+
constructor(db, agentId, errorMessage) {
|
|
39
|
+
this.db = db;
|
|
40
|
+
const version = agentTranscriptSchemaVersion(db, errorMessage);
|
|
41
|
+
this.#schemaVersion = version;
|
|
42
|
+
this.#utf8Storage = db.prepare("PRAGMA encoding").get()?.encoding === "UTF-8";
|
|
43
|
+
assertAgentTranscriptIdentity(db, agentId, version, errorMessage);
|
|
44
|
+
const required = {
|
|
45
|
+
transcript_events: ["session_id", "seq", "event_json", "created_at"],
|
|
46
|
+
session_transcript_active_events: ["session_id", "active_position", "event_seq"],
|
|
47
|
+
};
|
|
48
|
+
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name));
|
|
49
|
+
for (const [table, names] of Object.entries(required)) {
|
|
50
|
+
const found = columns(table);
|
|
51
|
+
if (names.some(name => !found.has(name)))
|
|
52
|
+
throw new Error(`Unsupported transcript capabilities: ${table}`);
|
|
53
|
+
}
|
|
54
|
+
const payload = columns("transcript_events");
|
|
55
|
+
this.#compressed = payload.has("event_zstd") && payload.has("event_utf8_bytes");
|
|
56
|
+
if ((version >= 23 || payload.has("event_zstd") || payload.has("event_utf8_bytes")) && !this.#compressed) {
|
|
57
|
+
throw new Error("Unsupported transcript compression capabilities");
|
|
58
|
+
}
|
|
59
|
+
this.#cold = columns("session_transcript_cold_archives").has("session_id");
|
|
60
|
+
if (version >= 20 && !this.#cold)
|
|
61
|
+
throw new Error("Unsupported transcript cold-storage capabilities");
|
|
62
|
+
}
|
|
63
|
+
/** Include storage availability in incremental fingerprints, not just hot row counts. */
|
|
64
|
+
coldSql(sessionIdExpression) {
|
|
65
|
+
return this.#cold ? `EXISTS (SELECT 1 FROM session_transcript_cold_archives cold WHERE cold.session_id=${sessionIdExpression})` : "0";
|
|
66
|
+
}
|
|
67
|
+
/** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
|
|
68
|
+
read(sessionId, limits = {}) {
|
|
69
|
+
if (!this.db.isTransaction)
|
|
70
|
+
throw new Error("Transcript read requires a snapshot transaction");
|
|
71
|
+
// Long-lived audit/source readers may outlive a host migration. Reopen before
|
|
72
|
+
// trusting cached capabilities; absence of old hot rows is not deletion proof.
|
|
73
|
+
if (this.db.prepare("PRAGMA user_version").get()?.user_version !== this.#schemaVersion)
|
|
74
|
+
return { kind: "unreadable" };
|
|
75
|
+
if (this.#cold && this.db.prepare("SELECT 1 FROM session_transcript_cold_archives WHERE session_id=?").get(sessionId)) {
|
|
76
|
+
return { kind: "cold" };
|
|
77
|
+
}
|
|
78
|
+
const maxEvents = limits.maxEvents ?? 50_000, maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
|
|
79
|
+
const maxSeq = limits.maxSeq ?? Number.MAX_SAFE_INTEGER;
|
|
80
|
+
// SQLite's BLOB length measures the database encoding, not decoded UTF-8.
|
|
81
|
+
// Non-UTF-8 stores still enforce the byte budget below during bounded iteration.
|
|
82
|
+
const bytes = !this.#utf8Storage ? "0" : this.#compressed
|
|
83
|
+
? "COALESCE(length(CAST(e.event_json AS BLOB)),e.event_utf8_bytes,0)" : "length(CAST(e.event_json AS BLOB))";
|
|
84
|
+
const size = this.db.prepare(`SELECT COUNT(*) n,COALESCE(SUM(${bytes}),0) bytes
|
|
85
|
+
${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=?`).get(sessionId, maxSeq);
|
|
86
|
+
if (Number(size.n) > maxEvents || Number(size.bytes) > maxBytes)
|
|
87
|
+
return { kind: "oversized" };
|
|
88
|
+
const rows = [];
|
|
89
|
+
let totalBytes = 0;
|
|
90
|
+
for (const row of this.db.prepare(`SELECT e.seq,e.event_json eventJson,e.created_at createdAt
|
|
91
|
+
${this.#compressed ? ",e.event_zstd compressed,e.event_utf8_bytes rawBytes" : ""}
|
|
92
|
+
${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=? ORDER BY a.active_position`).iterate(sessionId, maxSeq)) {
|
|
93
|
+
let eventJson;
|
|
94
|
+
if (typeof row.eventJson === "string")
|
|
95
|
+
eventJson = row.eventJson;
|
|
96
|
+
else {
|
|
97
|
+
const compressed = row.compressed, rawBytes = row.rawBytes;
|
|
98
|
+
if (!(compressed instanceof Uint8Array) || !compressed.byteLength || compressed.byteLength > MAX_COMPRESSED_BYTES ||
|
|
99
|
+
typeof rawBytes !== "number" || !Number.isSafeInteger(rawBytes) || rawBytes <= 0 || rawBytes > MAX_COMPRESSED_BYTES) {
|
|
100
|
+
return { kind: "unreadable" };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const decoded = zstdDecompressSync(compressed, { maxOutputLength: rawBytes });
|
|
104
|
+
if (decoded.byteLength !== rawBytes)
|
|
105
|
+
return { kind: "unreadable" };
|
|
106
|
+
eventJson = utf8.decode(decoded);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { kind: "unreadable" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
totalBytes += Buffer.byteLength(eventJson);
|
|
113
|
+
if (totalBytes > maxBytes)
|
|
114
|
+
return { kind: "oversized" };
|
|
115
|
+
rows.push({ seq: Number(row.seq), eventJson, createdAt: Number(row.createdAt) });
|
|
116
|
+
}
|
|
117
|
+
return { kind: "ready", rows };
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/src/contracts.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export type SessionSearchFilter = {
|
|
|
31
31
|
export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
|
|
32
32
|
export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]>, "lexicalOnly"> & {
|
|
33
33
|
corpora?: readonly string[];
|
|
34
|
-
/** Internal
|
|
34
|
+
/** Internal passage budget; oversized matched chunks are omitted, never sliced. */
|
|
35
35
|
maxSnippetChars?: number;
|
|
36
36
|
sessionFilter?: SessionSearchFilter;
|
|
37
37
|
requestContext?: MemoryRequestContext;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { RetrievalTelemetry } from "./retrieval-telemetry.js";
|
|
2
2
|
type Whisperer = "skill" | "memory";
|
|
3
|
-
type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed";
|
|
3
|
+
type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "judge_candidate_failed" | "input_too_large";
|
|
4
4
|
/** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
|
|
5
5
|
export declare class WhispererDiagnostics {
|
|
6
6
|
#private;
|
|
7
7
|
static shared(): WhispererDiagnostics;
|
|
8
8
|
record(agentId: string, whisperer: Whisperer, outcome: Outcome): void;
|
|
9
9
|
measureMemory(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
|
|
10
|
+
measureSearch(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
|
|
10
11
|
snapshot(agentId: string): {
|
|
11
12
|
skill: {
|
|
12
13
|
cancelled?: number | undefined;
|
|
@@ -24,6 +25,7 @@ export declare class WhispererDiagnostics {
|
|
|
24
25
|
recall_not_needed?: number | undefined;
|
|
25
26
|
queries_generated?: number | undefined;
|
|
26
27
|
judge_candidate_failed?: number | undefined;
|
|
28
|
+
input_too_large?: number | undefined;
|
|
27
29
|
};
|
|
28
30
|
memory: {
|
|
29
31
|
cancelled?: number | undefined;
|
|
@@ -41,6 +43,7 @@ export declare class WhispererDiagnostics {
|
|
|
41
43
|
recall_not_needed?: number | undefined;
|
|
42
44
|
queries_generated?: number | undefined;
|
|
43
45
|
judge_candidate_failed?: number | undefined;
|
|
46
|
+
input_too_large?: number | undefined;
|
|
44
47
|
};
|
|
45
48
|
telemetry: {
|
|
46
49
|
scope: string;
|
|
@@ -50,6 +53,7 @@ export declare class WhispererDiagnostics {
|
|
|
50
53
|
outcomes: {
|
|
51
54
|
ok?: number | undefined;
|
|
52
55
|
cancelled?: number | undefined;
|
|
56
|
+
partial?: number | undefined;
|
|
53
57
|
skipped?: number | undefined;
|
|
54
58
|
failed?: number | undefined;
|
|
55
59
|
empty?: number | undefined;
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -24,6 +24,9 @@ export class WhispererDiagnostics {
|
|
|
24
24
|
measureMemory(agentId, observation) {
|
|
25
25
|
this.#entry(agentId).telemetry.record("memoryWhisperer", observation);
|
|
26
26
|
}
|
|
27
|
+
measureSearch(agentId, observation) {
|
|
28
|
+
this.#entry(agentId).telemetry.record("memorySearch", observation);
|
|
29
|
+
}
|
|
27
30
|
snapshot(agentId) {
|
|
28
31
|
const entry = this.#agents.get(agentId);
|
|
29
32
|
return { skill: { ...entry?.counts.skill }, memory: { ...entry?.counts.memory },
|
|
@@ -5,7 +5,7 @@ import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
|
-
import {
|
|
8
|
+
import { AgentTranscriptReader } from "./agent-transcript.js";
|
|
9
9
|
/** Files contain branch trees; keep the selected leaf, not abandoned sibling conversations. */
|
|
10
10
|
function selectedBranch(events) {
|
|
11
11
|
const nodes = new Map(events.filter(e => e.id).map(e => [e.id, e]));
|
|
@@ -57,10 +57,11 @@ export async function* readInsideOutSources(paths, errors) {
|
|
|
57
57
|
const db = new DatabaseSync(paths.databasePath, { readOnly: true });
|
|
58
58
|
try {
|
|
59
59
|
db.exec("PRAGMA busy_timeout=1000");
|
|
60
|
-
|
|
60
|
+
const transcripts = new AgentTranscriptReader(db, paths.agentId);
|
|
61
61
|
const sessions = db.prepare(`SELECT w.session_id sessionId, COALESCE(w.channel,c.channel,'local') channel,
|
|
62
62
|
COALESCE(w.account_id,c.account_id,'') account, COALESCE(r.generation,'') revision,
|
|
63
|
-
COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail
|
|
63
|
+
COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail,
|
|
64
|
+
${transcripts.coldSql("w.session_id")} cold FROM session_windows w
|
|
64
65
|
LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
65
66
|
LEFT JOIN transcript_rewrite_watermarks r ON r.session_id=w.session_id
|
|
66
67
|
WHERE (? IS NULL OR w.session_id=?) ORDER BY w.started_at,w.session_id`).all(paths.sessionId ?? null, paths.sessionId ?? null);
|
|
@@ -83,15 +84,23 @@ export async function* readInsideOutSources(paths, errors) {
|
|
|
83
84
|
}
|
|
84
85
|
for (const s of sessions) {
|
|
85
86
|
const sessionId = String(s.sessionId), source = `live:${sessionId}`;
|
|
86
|
-
yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail]), read: () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail, s.cold]), read: () => {
|
|
88
|
+
db.exec("BEGIN");
|
|
89
|
+
try {
|
|
90
|
+
const snapshot = transcripts.read(sessionId, { maxSeq: Number(s.tail) });
|
|
91
|
+
if (snapshot.kind !== "ready")
|
|
92
|
+
throw new Error(`Transcript unavailable: ${snapshot.kind}`);
|
|
93
|
+
const events = snapshot.rows.map(row => {
|
|
94
|
+
const event = JSON.parse(row.eventJson);
|
|
95
|
+
event.seq = row.seq;
|
|
96
|
+
event.timestamp ??= new Date(row.createdAt).toISOString();
|
|
97
|
+
return event;
|
|
98
|
+
});
|
|
99
|
+
return { sessionId, source, events, ...metadata.get(sessionId) };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
db.exec("COMMIT");
|
|
103
|
+
}
|
|
95
104
|
} };
|
|
96
105
|
}
|
|
97
106
|
}
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
56
56
|
outcomes: {
|
|
57
57
|
ok?: number | undefined;
|
|
58
58
|
cancelled?: number | undefined;
|
|
59
|
+
partial?: number | undefined;
|
|
59
60
|
skipped?: number | undefined;
|
|
60
61
|
failed?: number | undefined;
|
|
61
62
|
empty?: number | undefined;
|
|
@@ -219,8 +220,8 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
219
220
|
};
|
|
220
221
|
}): MaintenanceTask | undefined;
|
|
221
222
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
222
|
-
/**
|
|
223
|
-
|
|
223
|
+
/** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
|
|
224
|
+
searchCandidates(queries: QueryPair, opts: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
224
225
|
searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
|
|
225
226
|
readFile(params: {
|
|
226
227
|
relPath: string;
|
package/dist/src/manager.js
CHANGED
|
@@ -831,8 +831,8 @@ export class QmdMemoryManager {
|
|
|
831
831
|
opts?.signal?.throwIfAborted();
|
|
832
832
|
return this.#renderSearchHits(hits, store, opts);
|
|
833
833
|
}
|
|
834
|
-
/**
|
|
835
|
-
async
|
|
834
|
+
/** Shared discovery: exact trained recipe, scoped before either retrieval lane. */
|
|
835
|
+
async searchCandidates(queries, opts) {
|
|
836
836
|
return this.#enqueue(async () => {
|
|
837
837
|
opts.signal?.throwIfAborted();
|
|
838
838
|
const collections = this.#collectionNames(opts.corpora);
|
|
@@ -840,12 +840,18 @@ export class QmdMemoryManager {
|
|
|
840
840
|
return [];
|
|
841
841
|
if (this.#sessions && collections.includes(this.#sessions.collection))
|
|
842
842
|
await this.#refreshSessionMetadata();
|
|
843
|
+
const sessions = this.#sessions;
|
|
844
|
+
let allowedPaths = opts.sessionFilter && sessions && collections.includes(sessions.collection)
|
|
845
|
+
? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
|
|
846
|
+
if (collections.includes(EXTRACTED_COLLECTION)) {
|
|
847
|
+
allowedPaths = { ...allowedPaths, [EXTRACTED_COLLECTION]: this.#currentExtracted(undefined, opts.sessionFilter).map(extractedPath) };
|
|
848
|
+
}
|
|
843
849
|
const store = await this.#getAnalysisStore();
|
|
844
850
|
const hits = new Map();
|
|
845
851
|
// Serialize native QMD work. Cancellation prevents further queries/collections.
|
|
846
852
|
for (const lane of ["lex", "vec"]) {
|
|
847
853
|
opts.signal?.throwIfAborted();
|
|
848
|
-
for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal)) {
|
|
854
|
+
for (const hit of await trainingCandidates(store, queries[lane], collections, lane, opts.signal, allowedPaths)) {
|
|
849
855
|
const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
|
|
850
856
|
if (!hits.has(key))
|
|
851
857
|
hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
|
|
2
|
+
import type { QueryConversation, QueryPair } from "./query-contract.js";
|
|
3
|
+
export type MemoryCandidateManager = {
|
|
4
|
+
searchCandidates(queries: QueryPair, options: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
5
|
+
};
|
|
6
|
+
export declare function memoryPassageId(text: string): string;
|
|
7
|
+
export type MemorySearchObservation = {
|
|
8
|
+
retrievalMs: number;
|
|
9
|
+
judgeMs: number;
|
|
10
|
+
candidates: number;
|
|
11
|
+
eligible: number;
|
|
12
|
+
requestsSucceeded: number;
|
|
13
|
+
requestsFailed: number;
|
|
14
|
+
};
|
|
15
|
+
/** Both entry points use the same complete passages and independent usefulness judgments. */
|
|
16
|
+
export declare function searchMemory(manager: MemoryCandidateManager, queries: QueryPair, options: CorpusSearchOptions & {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
conversation: QueryConversation;
|
|
20
|
+
asOf?: string;
|
|
21
|
+
minUsefulness?: number;
|
|
22
|
+
excludedPassages?: ReadonlyMap<string, number>;
|
|
23
|
+
onCandidates?: (observation: MemorySearchObservation) => void;
|
|
24
|
+
onJudgment?: (event: {
|
|
25
|
+
candidateIndex: number;
|
|
26
|
+
elapsedMs: number;
|
|
27
|
+
error?: unknown;
|
|
28
|
+
}) => void;
|
|
29
|
+
}): Promise<MemorySearchObservation & {
|
|
30
|
+
results: CorpusMemorySearchResult[];
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { MEMORY_PASSAGE_CHARS, duplicateMemoryPassage } from "./memory-passage.js";
|
|
3
|
+
import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
|
|
4
|
+
export function memoryPassageId(text) {
|
|
5
|
+
return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
/** Both entry points use the same complete passages and independent usefulness judgments. */
|
|
8
|
+
export async function searchMemory(manager, queries, options) {
|
|
9
|
+
const { signal } = options;
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const asOf = options.asOf ?? new Date().toISOString();
|
|
12
|
+
const started = performance.now();
|
|
13
|
+
const hits = await manager.searchCandidates(queries, {
|
|
14
|
+
corpora: options.corpora, sessionFilter: options.sessionFilter, requestContext: options.requestContext,
|
|
15
|
+
sources: options.sources, signal, maxSnippetChars: MEMORY_PASSAGE_CHARS,
|
|
16
|
+
});
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
const observation = { retrievalMs: performance.now() - started, judgeMs: 0,
|
|
19
|
+
candidates: hits.length, eligible: 0, requestsSucceeded: 0, requestsFailed: 0 };
|
|
20
|
+
const candidates = [];
|
|
21
|
+
for (const hit of hits) {
|
|
22
|
+
// Defense in depth before disclosing passages to the external judge.
|
|
23
|
+
if (options.corpora && !options.corpora.includes("all") && !options.corpora.includes(hit.corpus))
|
|
24
|
+
continue;
|
|
25
|
+
const excerpt = hit.snippet.trim();
|
|
26
|
+
if (!excerpt || excerpt.length > MEMORY_PASSAGE_CHARS || options.excludedPassages?.has(memoryPassageId(excerpt)) ||
|
|
27
|
+
duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate, text: candidate.snippet }))))
|
|
28
|
+
continue;
|
|
29
|
+
candidates.push({ ...hit, snippet: excerpt });
|
|
30
|
+
}
|
|
31
|
+
observation.eligible = candidates.length;
|
|
32
|
+
options.onCandidates?.(observation);
|
|
33
|
+
const judgeStarted = performance.now();
|
|
34
|
+
const judged = (await Promise.all(candidates.map(async (hit, candidateIndex) => {
|
|
35
|
+
const requestStarted = performance.now();
|
|
36
|
+
try {
|
|
37
|
+
const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(options.conversation, {
|
|
38
|
+
excerpt: hit.snippet, corpus: hit.corpus, sourcePath: hit.path,
|
|
39
|
+
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
40
|
+
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
41
|
+
}, asOf), options);
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
observation.requestsSucceeded++;
|
|
44
|
+
options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted });
|
|
45
|
+
// Discovery reciprocal ranks are not vector similarity or usefulness.
|
|
46
|
+
const { vectorScore: _vectorScore, textScore: _textScore, ...result } = hit;
|
|
47
|
+
return [{ ...result, score: probability }];
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (signal?.aborted)
|
|
51
|
+
return [];
|
|
52
|
+
observation.requestsFailed++;
|
|
53
|
+
options.onJudgment?.({ candidateIndex, elapsedMs: performance.now() - requestStarted, error });
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}))).flat();
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
observation.judgeMs = performance.now() - judgeStarted;
|
|
59
|
+
return { ...observation, results: judged.filter(hit => hit.score >= (options.minUsefulness ?? 0.7))
|
|
60
|
+
.sort((a, b) => b.score - a.score).slice(0, options.maxResults ?? 5) };
|
|
61
|
+
}
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
2
|
import type { UnblockMemoryConfig } from "./config.js";
|
|
3
|
-
import type { CorpusMemorySearchResult, CorpusSearchOptions } from "./contracts.js";
|
|
4
3
|
import type { WhispererDiagnostics } from "./diagnostics.js";
|
|
5
|
-
import type
|
|
4
|
+
import { type MemoryCandidateManager } from "./memory-search.js";
|
|
6
5
|
type MemoryWhispererRuntime = {
|
|
7
6
|
getMemorySearchManager(params: {
|
|
8
7
|
cfg: OpenClawConfig;
|
|
9
8
|
agentId: string;
|
|
10
9
|
}): Promise<{
|
|
11
|
-
manager:
|
|
12
|
-
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
13
|
-
searchWhisperer?(queries: QueryPair, opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
|
|
14
|
-
} | null;
|
|
10
|
+
manager: MemoryCandidateManager | null;
|
|
15
11
|
}>;
|
|
16
12
|
};
|
|
17
13
|
export declare function registerMemoryWhisperer(api: OpenClawPluginApi, runtime: MemoryWhispererRuntime, config: UnblockMemoryConfig["memoryWhisperer"], typesafe: UnblockMemoryConfig["typesafe"], diagnostics?: WhispererDiagnostics): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;
|