@unblocklabs/unblock-memory 0.3.19 → 0.3.21
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 +56 -4
- package/dist/src/config.d.ts +5 -0
- package/dist/src/config.js +21 -2
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/curation.js +40 -44
- package/dist/src/manager.d.ts +1 -0
- package/dist/src/manager.js +46 -26
- package/dist/src/memory-database.d.ts +6 -0
- package/dist/src/memory-database.js +174 -0
- package/dist/src/people-store.js +19 -21
- package/dist/src/plugin.js +55 -1
- package/dist/src/response-audit.js +4 -2
- package/dist/src/response-identity.js +5 -0
- package/dist/src/response-runtime.js +5 -5
- package/dist/src/response-store.d.ts +1 -1
- package/dist/src/response-store.js +36 -29
- package/dist/src/runtime.js +10 -2
- package/dist/src/slack-directory.d.ts +2 -0
- package/dist/src/slack-directory.js +7 -1
- package/dist/src/xsearch-bm25.d.ts +4 -0
- package/dist/src/xsearch-bm25.js +56 -0
- package/dist/src/xsearch.d.ts +62 -0
- package/dist/src/xsearch.js +124 -0
- package/openclaw.plugin.json +19 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Unblock Memory
|
|
2
2
|
|
|
3
|
+
## Hybrid search (`memory_xsearch`, opt-in)
|
|
4
|
+
|
|
5
|
+
`memory_search` remains vector-only. Enable `memory_xsearch` to combine vector
|
|
6
|
+
and BM25 retrieval, then independently score complete source excerpts with
|
|
7
|
+
TypeSafe. It is disabled by default and requires shared TypeSafe credentials
|
|
8
|
+
plus an explicit approved corpus list:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"xsearch": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"corpora": ["memory"],
|
|
15
|
+
"timeoutMs": 10000
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Place this under `plugins.entries.unblock-memory.config`. Enabling it approves
|
|
21
|
+
sending the query and selected excerpts from those corpora to TypeSafe. Skills
|
|
22
|
+
are excluded. Unapproved corpora are rejected; session filters apply to both
|
|
23
|
+
retrieval methods. `minScore` is final usefulness (0–1), not vector similarity.
|
|
24
|
+
The tool returns existing source spans with normal `memory_get` citations.
|
|
25
|
+
|
|
3
26
|
## Response quality tracking (opt-in)
|
|
4
27
|
|
|
5
28
|
`responseAudit` evaluates bounded human-agent exchanges in the background. It is
|
|
@@ -154,8 +177,9 @@ Choice confidence from Noul yes-probability. Multiple supported reasons can coex
|
|
|
154
177
|
`reportVersion` identifies composition/reporting semantics independently of the
|
|
155
178
|
judge rubric, allowing cached judgments to be re-reported without re-inference.
|
|
156
179
|
|
|
157
|
-
Results live in the agent's private
|
|
158
|
-
the memory index.
|
|
180
|
+
Results live in operator-only tables in the agent's private
|
|
181
|
+
`unblock-memory/unblock-memory.sqlite`, outside the memory index. These tables
|
|
182
|
+
are not searched or injected into agent prompts. They store judgments and source event references/hashes, not copies
|
|
159
183
|
of conversations. Identical successful inputs are cached; source rewrites invalidate
|
|
160
184
|
in-scope results on the next scan. Reports partition by fixed judge/rubric/context
|
|
161
185
|
configuration, UTC week, task type and agent model. They expose eligible/assessed
|
|
@@ -599,7 +623,7 @@ For optional read-only diagnostics, `memory_people_prime({ personId, agentName?,
|
|
|
599
623
|
draft: { blurb, citations: [{ path, from, lines }] } })` still reviews a snippet
|
|
600
624
|
without writing. Agents do not need this extra call in the normal update workflow.
|
|
601
625
|
|
|
602
|
-
Judgments are cached privately in `
|
|
626
|
+
Judgments are cached privately in `unblock-memory.sqlite` (maximum 2,000 entries), keyed
|
|
603
627
|
by person, agent, exact evidence/context, questions,
|
|
604
628
|
and judge version. No source text or credentials are stored in the cache.
|
|
605
629
|
Retrieval reruns against the current index; unchanged judgments are reused.
|
|
@@ -758,10 +782,38 @@ explicit intervals remain unchanged on upgrade; set them to `60` for hourly chec
|
|
|
758
782
|
|
|
759
783
|
Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
|
|
760
784
|
equivalent configured OpenClaw state directory). Durable agent-supplied event
|
|
761
|
-
dates
|
|
785
|
+
dates, maintenance proposals, people/dossiers and response audits live separately
|
|
786
|
+
in `unblock-memory.sqlite`, so a QMD
|
|
762
787
|
index rebuild does not discard them. The first lookup builds the index;
|
|
763
788
|
Markdown filesystem changes queue a debounced, serialized background refresh.
|
|
764
789
|
|
|
790
|
+
### Durable database migration (v0.3.20)
|
|
791
|
+
|
|
792
|
+
Each agent has two active plugin databases: rebuildable `index.sqlite` and durable
|
|
793
|
+
`unblock-memory.sqlite`. The latter uses WAL, private permissions and component
|
|
794
|
+
schema versions. Store modules and tool access remain separate: consolidating files
|
|
795
|
+
does not expose operator response audits to memory searches or whisperers.
|
|
796
|
+
|
|
797
|
+
**Stop the Gateway and any plugin CLI writers before upgrading.** On first
|
|
798
|
+
durable-store access, the plugin imports existing `curation.sqlite`, `people.sqlite`
|
|
799
|
+
and `response-audit.sqlite` files, including disabled features, in one transaction.
|
|
800
|
+
It includes committed WAL data, verifies row counts/values, integrity and foreign
|
|
801
|
+
keys, and records completion. Missing stores are normal. Unsupported or invalid
|
|
802
|
+
data aborts the import without a partial cutover; the next access retries.
|
|
803
|
+
QMD and transcript databases are not migrated.
|
|
804
|
+
|
|
805
|
+
Old files remain untouched as **inert recovery copies**, not active stores.
|
|
806
|
+
Completed migration never reimports or writes to them. Do not run old and new
|
|
807
|
+
plugin versions together: old writers can keep changing their separate files.
|
|
808
|
+
Back up the new database with SQLite's online backup API, or stop all writers
|
|
809
|
+
and safely checkpoint WAL first. Copying only a live `.sqlite` file is unsafe.
|
|
810
|
+
|
|
811
|
+
To roll back before any new writes, stop all writers, preserve the new database
|
|
812
|
+
and its WAL/SHM sidecars, and restore the old plugin against the retained files.
|
|
813
|
+
**After new writes, the legacy files are stale:** rollback requires an explicit
|
|
814
|
+
reverse data migration or accepting the loss of post-upgrade changes.
|
|
815
|
+
Retain recovery files until the upgrade is verified; cleanup is a separate step.
|
|
816
|
+
|
|
765
817
|
## Memory quality audit
|
|
766
818
|
|
|
767
819
|
`memory_audit_quality` is an on-demand, source-read-only audit. TypeSafe flags likely
|
package/dist/src/config.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export type UnblockMemoryConfig = {
|
|
|
42
42
|
enabled: boolean;
|
|
43
43
|
corpora: readonly string[];
|
|
44
44
|
};
|
|
45
|
+
xsearch: {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
corpora: readonly string[];
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
};
|
|
45
50
|
responseAudit: ResponseAuditConfig;
|
|
46
51
|
peoplePrimer: PeoplePrimerConfig;
|
|
47
52
|
people: {
|
package/dist/src/config.js
CHANGED
|
@@ -264,6 +264,7 @@ export function resolveConfig(value) {
|
|
|
264
264
|
typesafe: { ...DEFAULT_TYPESAFE_CONFIG },
|
|
265
265
|
qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
|
|
266
266
|
evidenceReview: { enabled: false, corpora: [] },
|
|
267
|
+
xsearch: { enabled: false, corpora: [], timeoutMs: 10000 },
|
|
267
268
|
responseAudit: resolveResponseAudit(undefined, DEFAULT_CORPORA),
|
|
268
269
|
peoplePrimer: resolvePeoplePrimer(undefined, DEFAULT_CORPORA, false),
|
|
269
270
|
people: DEFAULT_PEOPLE_CONFIG,
|
|
@@ -275,10 +276,28 @@ export function resolveConfig(value) {
|
|
|
275
276
|
throw new Error("unblock-memory config must be an object");
|
|
276
277
|
}
|
|
277
278
|
const config = value;
|
|
278
|
-
assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "peoplePrimer", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit", "evidenceReview", "responseAudit"], "config");
|
|
279
|
+
assertOnlyKeys(config, ["corpora", "keepEmbeddingModelWarm", "analysis", "people", "peoplePrimer", "skillWhisperer", "memoryWhisperer", "typesafe", "qualityAudit", "evidenceReview", "responseAudit", "xsearch"], "config");
|
|
279
280
|
const corpora = resolveCorpora(config.corpora);
|
|
280
281
|
const people = resolvePeople(config.people);
|
|
281
282
|
const peoplePrimer = resolvePeoplePrimer(config.peoplePrimer, corpora, people.enabled);
|
|
283
|
+
let xsearch = { enabled: false, corpora: [], timeoutMs: 10000 };
|
|
284
|
+
if (config.xsearch !== undefined) {
|
|
285
|
+
const value = config.xsearch;
|
|
286
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
287
|
+
throw new Error("xsearch must be an object");
|
|
288
|
+
const options = value;
|
|
289
|
+
assertOnlyKeys(options, ["enabled", "corpora", "timeoutMs"], "xsearch");
|
|
290
|
+
try {
|
|
291
|
+
const approved = resolveQualityAudit({ enabled: options.enabled, corpora: options.corpora }, corpora);
|
|
292
|
+
xsearch = { enabled: approved.enabled, corpora: approved.corpora,
|
|
293
|
+
timeoutMs: positiveInteger(options.timeoutMs, 10000, "xsearch.timeoutMs", 30000) };
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error instanceof Error)
|
|
297
|
+
throw new Error(error.message.replaceAll("qualityAudit", "xsearch"));
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
282
301
|
let evidenceReview = { enabled: false, corpora: [] };
|
|
283
302
|
if (config.evidenceReview !== undefined) {
|
|
284
303
|
const value = config.evidenceReview;
|
|
@@ -349,7 +368,7 @@ export function resolveConfig(value) {
|
|
|
349
368
|
if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
|
|
350
369
|
throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
|
|
351
370
|
}
|
|
352
|
-
return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, peoplePrimer, skillWhisperer,
|
|
371
|
+
return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, peoplePrimer, skillWhisperer, xsearch,
|
|
353
372
|
qualityAudit: resolveQualityAudit(config.qualityAudit, corpora),
|
|
354
373
|
evidenceReview,
|
|
355
374
|
responseAudit: resolveResponseAudit(config.responseAudit, corpora),
|
package/dist/src/contracts.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export type SessionSearchFilter = {
|
|
|
24
24
|
export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
|
|
25
25
|
export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
|
|
26
26
|
corpora?: readonly string[];
|
|
27
|
-
/** Internal
|
|
27
|
+
/** Internal hint/reranking budget; oversized matched chunks are omitted, never sliced. */
|
|
28
28
|
maxSnippetChars?: number;
|
|
29
29
|
sessionFilter?: SessionSearchFilter;
|
|
30
30
|
requestContext?: MemoryRequestContext;
|
package/dist/src/curation.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
|
-
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { openMemoryDatabase } from "./memory-database.js";
|
|
5
3
|
const TEMPORAL_BASES = ["path", "frontmatter", "session", "agent_verified"];
|
|
6
4
|
const MAINTENANCE_TASK_TYPES = ["ambiguous_event_time", "exact_duplicate", "quality_review"];
|
|
7
5
|
const MAINTENANCE_STATUSES = ["pending", "resolved", "deferred", "irrelevant"];
|
|
@@ -42,35 +40,41 @@ export function chunkFingerprint(text) {
|
|
|
42
40
|
export class CurationStore {
|
|
43
41
|
#db;
|
|
44
42
|
constructor(path) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
43
|
+
this.#db = openMemoryDatabase(path);
|
|
44
|
+
try {
|
|
45
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
46
|
+
const version = this.#db.prepare("SELECT version FROM memory_schema WHERE component='curation'").get()?.version;
|
|
47
|
+
if (version !== undefined && version !== 1)
|
|
48
|
+
throw new Error("Unsupported curation schema version");
|
|
49
|
+
this.#db.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS temporal_annotations (
|
|
51
|
+
corpus TEXT NOT NULL,
|
|
52
|
+
collection TEXT NOT NULL,
|
|
53
|
+
path TEXT NOT NULL,
|
|
54
|
+
content_fingerprint TEXT NOT NULL DEFAULT '',
|
|
55
|
+
event_time TEXT NOT NULL,
|
|
56
|
+
basis TEXT NOT NULL CHECK (basis IN ('path', 'frontmatter', 'session', 'agent_verified')),
|
|
57
|
+
evidence TEXT NOT NULL,
|
|
58
|
+
qmd_hash TEXT,
|
|
59
|
+
qmd_seq INTEGER,
|
|
60
|
+
created_at TEXT NOT NULL,
|
|
61
|
+
updated_at TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (corpus, collection, path, content_fingerprint)
|
|
63
|
+
);
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
`);
|
|
66
|
+
this.#ensureMaintenanceSchema();
|
|
67
|
+
this.#db.exec(`CREATE TABLE IF NOT EXISTS quality_judgments (
|
|
68
|
+
cache_key TEXT PRIMARY KEY,
|
|
69
|
+
noise REAL NOT NULL CHECK (noise BETWEEN 0 AND 1),
|
|
70
|
+
evidence REAL NOT NULL CHECK (evidence BETWEEN 0 AND 1)
|
|
71
|
+
)`);
|
|
72
|
+
this.#db.exec("INSERT OR IGNORE INTO memory_schema VALUES ('curation',1); COMMIT");
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
this.#db.close();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
74
78
|
}
|
|
75
79
|
#ensureMaintenanceSchema() {
|
|
76
80
|
this.#db.exec(`
|
|
@@ -93,19 +97,11 @@ export class CurationStore {
|
|
|
93
97
|
const schema = this.#db.prepare("SELECT sql FROM sqlite_master WHERE name = 'maintenance_tasks'")
|
|
94
98
|
.get();
|
|
95
99
|
if (!schema.sql.includes("'quality_review'")) {
|
|
96
|
-
this.#db.exec("
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
DROP TABLE maintenance_tasks;
|
|
102
|
-
ALTER TABLE maintenance_tasks_quality RENAME TO maintenance_tasks;`);
|
|
103
|
-
this.#db.exec("COMMIT");
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
this.#db.exec("ROLLBACK");
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
100
|
+
this.#db.exec(schema.sql.replace("maintenance_tasks", "maintenance_tasks_quality")
|
|
101
|
+
.replace("'exact_duplicate'", "'exact_duplicate', 'quality_review'"));
|
|
102
|
+
this.#db.exec(`INSERT INTO maintenance_tasks_quality SELECT * FROM maintenance_tasks;
|
|
103
|
+
DROP TABLE maintenance_tasks;
|
|
104
|
+
ALTER TABLE maintenance_tasks_quality RENAME TO maintenance_tasks;`);
|
|
109
105
|
}
|
|
110
106
|
this.#db.exec(`
|
|
111
107
|
CREATE INDEX IF NOT EXISTS maintenance_tasks_status_created
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -194,6 +194,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
194
194
|
};
|
|
195
195
|
}): MaintenanceTask | undefined;
|
|
196
196
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
197
|
+
searchBm25(query: string, opts: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
197
198
|
searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
|
|
198
199
|
readFile(params: {
|
|
199
200
|
relPath: string;
|
package/dist/src/manager.js
CHANGED
|
@@ -14,6 +14,7 @@ import { qualityTaskPresence } from "./quality-triage.js";
|
|
|
14
14
|
import { reviewIndexedClaim } from "./evidence-review.js";
|
|
15
15
|
import { reviewClusterIngestion } from "./cluster-review.js";
|
|
16
16
|
import { abortable } from "./abortable.js";
|
|
17
|
+
import { xsearchBm25 } from "./xsearch-bm25.js";
|
|
17
18
|
const DEFAULT_READ_LINES = 120;
|
|
18
19
|
const MAX_READ_CHARS = 12_000;
|
|
19
20
|
const WATCH_DEBOUNCE_MS = 250;
|
|
@@ -412,34 +413,34 @@ export class QmdMemoryManager {
|
|
|
412
413
|
])),
|
|
413
414
|
},
|
|
414
415
|
});
|
|
415
|
-
enableSecureDelete(store);
|
|
416
|
-
ensureMemoryAnalysisSchema(store.internal.db);
|
|
417
|
-
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
418
|
-
const configuredCollections = new Set(this.#qmdSources().map((source) => source.collection));
|
|
419
|
-
const staleCollections = (await store.getStatus()).collections
|
|
420
|
-
.map((collection) => collection.name)
|
|
421
|
-
.filter((collection) => !configuredCollections.has(collection));
|
|
422
|
-
const appearsInAnalysis = store.internal.db.prepare(`
|
|
423
|
-
SELECT 1
|
|
424
|
-
FROM memory_analysis_memberships membership
|
|
425
|
-
JOIN documents document ON document.hash = membership.hash
|
|
426
|
-
WHERE membership.run_id = (
|
|
427
|
-
SELECT id FROM memory_analysis_runs
|
|
428
|
-
WHERE completed_at IS NOT NULL
|
|
429
|
-
ORDER BY completed_at DESC, created_at DESC, id DESC
|
|
430
|
-
LIMIT 1
|
|
431
|
-
) AND document.collection = ?
|
|
432
|
-
LIMIT 1
|
|
433
|
-
`);
|
|
434
|
-
const prunedAnalysisInput = staleCollections.some((collection) => appearsInAnalysis.get(collection));
|
|
435
|
-
const prunedDocuments = await pruneStaleCollections(store, configuredCollections);
|
|
436
|
-
if (prunedDocuments > 0 && prunedAnalysisInput)
|
|
437
|
-
markMemoryAnalysisStale(store.internal.db);
|
|
438
416
|
try {
|
|
417
|
+
enableSecureDelete(store);
|
|
418
|
+
ensureMemoryAnalysisSchema(store.internal.db);
|
|
419
|
+
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
420
|
+
const configuredCollections = new Set(this.#qmdSources().map((source) => source.collection));
|
|
421
|
+
const staleCollections = (await store.getStatus()).collections
|
|
422
|
+
.map((collection) => collection.name)
|
|
423
|
+
.filter((collection) => !configuredCollections.has(collection));
|
|
424
|
+
const appearsInAnalysis = store.internal.db.prepare(`
|
|
425
|
+
SELECT 1
|
|
426
|
+
FROM memory_analysis_memberships membership
|
|
427
|
+
JOIN documents document ON document.hash = membership.hash
|
|
428
|
+
WHERE membership.run_id = (
|
|
429
|
+
SELECT id FROM memory_analysis_runs
|
|
430
|
+
WHERE completed_at IS NOT NULL
|
|
431
|
+
ORDER BY completed_at DESC, created_at DESC, id DESC
|
|
432
|
+
LIMIT 1
|
|
433
|
+
) AND document.collection = ?
|
|
434
|
+
LIMIT 1
|
|
435
|
+
`);
|
|
436
|
+
const prunedAnalysisInput = staleCollections.some((collection) => appearsInAnalysis.get(collection));
|
|
437
|
+
const prunedDocuments = await pruneStaleCollections(store, configuredCollections);
|
|
438
|
+
if (prunedDocuments > 0 && prunedAnalysisInput)
|
|
439
|
+
markMemoryAnalysisStale(store.internal.db);
|
|
439
440
|
await ensureSemanticChunking(store);
|
|
440
441
|
}
|
|
441
442
|
catch (error) {
|
|
442
|
-
await store.close();
|
|
443
|
+
await store.close().catch(() => undefined);
|
|
443
444
|
throw error;
|
|
444
445
|
}
|
|
445
446
|
this.#cleanupRemovedDocuments = (changedDocuments) => {
|
|
@@ -834,10 +835,13 @@ export class QmdMemoryManager {
|
|
|
834
835
|
expand: false,
|
|
835
836
|
});
|
|
836
837
|
opts?.signal?.throwIfAborted();
|
|
838
|
+
return this.#searchResults(hits, store, opts);
|
|
839
|
+
}
|
|
840
|
+
async #searchResults(hits, store, opts, method = "vector") {
|
|
837
841
|
const tokenizer = store.internal?.llm;
|
|
838
842
|
const results = [];
|
|
839
843
|
for (const hit of hits) {
|
|
840
|
-
//
|
|
844
|
+
// Hints and reranking must retain the entire matched chunk, even when expanded
|
|
841
845
|
// turn/message context exceeds their budget. Ordinary search is unchanged.
|
|
842
846
|
if (hit.bestChunk.length > (opts?.maxSnippetChars ?? Infinity))
|
|
843
847
|
continue;
|
|
@@ -861,7 +865,7 @@ export class QmdMemoryManager {
|
|
|
861
865
|
path: hit.file,
|
|
862
866
|
...span,
|
|
863
867
|
score: hit.score,
|
|
864
|
-
vectorScore: hit.score,
|
|
868
|
+
...(method === "vector" ? { vectorScore: hit.score } : { textScore: hit.score }),
|
|
865
869
|
snippet: selected.text,
|
|
866
870
|
source: "memory",
|
|
867
871
|
corpus,
|
|
@@ -871,6 +875,22 @@ export class QmdMemoryManager {
|
|
|
871
875
|
}
|
|
872
876
|
return results;
|
|
873
877
|
}
|
|
878
|
+
async searchBm25(query, opts) {
|
|
879
|
+
if (opts.sources && !opts.sources.includes("memory"))
|
|
880
|
+
return [];
|
|
881
|
+
const collections = this.#collectionNames(opts.corpora);
|
|
882
|
+
opts.signal?.throwIfAborted();
|
|
883
|
+
await abortable(this.#operationChain ?? Promise.resolve(), opts.signal);
|
|
884
|
+
const sessions = this.#sessions;
|
|
885
|
+
if (opts.sessionFilter && sessions && collections.includes(sessions.collection))
|
|
886
|
+
await this.#refreshSessionMetadata();
|
|
887
|
+
const allowedPaths = opts.sessionFilter && sessions && collections.includes(sessions.collection)
|
|
888
|
+
? sessionAllowedPaths(this.#sessionMetadata, sessions.collection, opts.sessionFilter) : undefined;
|
|
889
|
+
const store = await this.#getAnalysisStore();
|
|
890
|
+
opts.signal?.throwIfAborted();
|
|
891
|
+
const hits = xsearchBm25(store.internal.db, query, collections, opts.maxResults ?? 5, allowedPaths);
|
|
892
|
+
return this.#searchResults(hits, store, opts, "bm25");
|
|
893
|
+
}
|
|
874
894
|
async searchSkills(query, minScore, limit) {
|
|
875
895
|
const collections = this.#skillCollectionNames();
|
|
876
896
|
if (collections.length === 0)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
export declare const MEMORY_DATABASE = "unblock-memory.sqlite";
|
|
3
|
+
/** Separate domain stores share settings, not a monolithic data-access API. */
|
|
4
|
+
export declare function openMemoryDatabase(path: string): DatabaseSync;
|
|
5
|
+
/** File existence no longer tells us which feature has initialized its tables. */
|
|
6
|
+
export declare function hasMemoryTable(path: string, table: string): boolean;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
export const MEMORY_DATABASE = "unblock-memory.sqlite";
|
|
5
|
+
const legacyStores = [
|
|
6
|
+
{ file: "curation.sqlite", component: "curation", tables: [
|
|
7
|
+
"temporal_annotations", "maintenance_tasks", "quality_judgments",
|
|
8
|
+
] },
|
|
9
|
+
{ file: "people.sqlite", component: "people", tables: [
|
|
10
|
+
"companies", "people", "person_identities", "person_dossiers", "people_todos",
|
|
11
|
+
"person_whisper_receipts", "person_evidence_receipts", "person_dossier_changes", "person_primer_judgments",
|
|
12
|
+
] },
|
|
13
|
+
{ file: "response-audit.sqlite", component: "responses", tables: [
|
|
14
|
+
"response_lease", "response_results", "response_scans", "response_checkpoints", "response_cursors",
|
|
15
|
+
"response_schedule", "response_stages", "response_stage_links", "response_review_tasks",
|
|
16
|
+
"response_review_evidence", "response_review_decisions", "response_review_versions", "response_annotations",
|
|
17
|
+
] },
|
|
18
|
+
];
|
|
19
|
+
function identifier(name) { return `"${name.replaceAll('"', '""')}"`; }
|
|
20
|
+
function requireRegularFile(path) {
|
|
21
|
+
const file = lstatSync(path, { throwIfNoEntry: false });
|
|
22
|
+
if (file && !file.isFile())
|
|
23
|
+
throw new Error("Memory database must be a regular file, not a symlink");
|
|
24
|
+
}
|
|
25
|
+
function enableWal(db) {
|
|
26
|
+
// Switching journal modes can return SQLITE_BUSY without invoking busy_timeout.
|
|
27
|
+
const deadline = Date.now() + 5000;
|
|
28
|
+
for (;;) {
|
|
29
|
+
try {
|
|
30
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!(error instanceof Error) || !("errcode" in error) || error.errcode !== 5 || Date.now() >= deadline)
|
|
35
|
+
throw error;
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(25, Math.max(0, deadline - Date.now())));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Separate domain stores share settings, not a monolithic data-access API. */
|
|
41
|
+
export function openMemoryDatabase(path) {
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
requireRegularFile(path);
|
|
44
|
+
const descriptor = openSync(path, constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
45
|
+
try {
|
|
46
|
+
fchmodSync(descriptor, 0o600);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
closeSync(descriptor);
|
|
50
|
+
}
|
|
51
|
+
const db = new DatabaseSync(path);
|
|
52
|
+
try {
|
|
53
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
54
|
+
enableWal(db);
|
|
55
|
+
db.exec(`PRAGMA foreign_keys=ON;
|
|
56
|
+
PRAGMA trusted_schema=OFF;
|
|
57
|
+
CREATE TABLE IF NOT EXISTS memory_schema (component TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;`);
|
|
58
|
+
// Explicit standalone store paths remain useful to tests and offline tools.
|
|
59
|
+
// Only the production filename opts into sibling-file consolidation.
|
|
60
|
+
if (basename(path) === MEMORY_DATABASE)
|
|
61
|
+
consolidate(db, dirname(path));
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
db.close();
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function consolidate(db, directory) {
|
|
70
|
+
const completed = () => {
|
|
71
|
+
const version = db.prepare("SELECT version FROM memory_schema WHERE component='storage'").get()?.version;
|
|
72
|
+
if (version !== undefined && version !== 1)
|
|
73
|
+
throw new Error("Unsupported durable storage version");
|
|
74
|
+
return version === 1;
|
|
75
|
+
};
|
|
76
|
+
if (completed())
|
|
77
|
+
return;
|
|
78
|
+
const sources = legacyStores.filter(source => lstatSync(join(directory, source.file), { throwIfNoEntry: false }));
|
|
79
|
+
const attached = [];
|
|
80
|
+
try {
|
|
81
|
+
for (const source of sources) {
|
|
82
|
+
const path = join(directory, source.file);
|
|
83
|
+
requireRegularFile(path);
|
|
84
|
+
const alias = `legacy_${source.component}`;
|
|
85
|
+
db.prepare(`ATTACH DATABASE ? AS ${identifier(alias)}`).run(path);
|
|
86
|
+
attached.push(alias);
|
|
87
|
+
}
|
|
88
|
+
// Attached legacy writer locks cover the entire snapshot/copy/verification.
|
|
89
|
+
// They cannot stop old binaries writing again later: upgrade with writers stopped.
|
|
90
|
+
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
91
|
+
try {
|
|
92
|
+
if (!completed()) {
|
|
93
|
+
const existing = db.prepare("SELECT name FROM main.sqlite_schema WHERE type='table' AND name!='memory_schema'").get();
|
|
94
|
+
if (existing)
|
|
95
|
+
throw new Error("Unmarked durable database is not empty; refusing to overwrite it");
|
|
96
|
+
for (const source of sources) {
|
|
97
|
+
const schema = identifier(`legacy_${source.component}`);
|
|
98
|
+
const version = Number(db.prepare(`PRAGMA ${schema}.user_version`).get()?.user_version);
|
|
99
|
+
if (source.component === "people" ? version < 0 || version > 4 : version !== 0) {
|
|
100
|
+
throw new Error(`Unsupported legacy ${source.component} schema version`);
|
|
101
|
+
}
|
|
102
|
+
const integrity = db.prepare(`PRAGMA ${schema}.integrity_check`).all();
|
|
103
|
+
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok")
|
|
104
|
+
throw new Error(`Invalid legacy ${source.component} database`);
|
|
105
|
+
const objects = db.prepare(`SELECT type,name,tbl_name,sql FROM ${schema}.sqlite_schema WHERE name NOT LIKE 'sqlite_%'`).all();
|
|
106
|
+
if (source.component === "people" && version === 0 && objects.length)
|
|
107
|
+
throw new Error("Unversioned legacy people schema");
|
|
108
|
+
for (const object of objects) {
|
|
109
|
+
if ((object.type !== "table" && object.type !== "index") || typeof object.sql !== "string" ||
|
|
110
|
+
!source.tables.some(name => name === object.tbl_name) || /CREATE\s+VIRTUAL\s+TABLE/i.test(object.sql)) {
|
|
111
|
+
throw new Error(`Unsupported legacy ${source.component} schema object`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const tables = objects.filter(object => object.type === "table");
|
|
115
|
+
if (source.component === "people" && version > 0) {
|
|
116
|
+
const required = ["companies", "people", "person_identities", "person_dossiers", "people_todos",
|
|
117
|
+
...(version >= 2 ? ["person_whisper_receipts"] : []),
|
|
118
|
+
...(version === 2 ? ["person_evidence_receipts"] : []),
|
|
119
|
+
...(version === 4 ? ["person_dossier_changes"] : [])];
|
|
120
|
+
if (required.some(name => !tables.some(table => table.name === name)))
|
|
121
|
+
throw new Error("Incomplete legacy people schema");
|
|
122
|
+
}
|
|
123
|
+
for (const table of tables)
|
|
124
|
+
db.exec(String(table.sql));
|
|
125
|
+
for (const table of tables) {
|
|
126
|
+
const name = identifier(String(table.name));
|
|
127
|
+
db.exec(`INSERT INTO main.${name} SELECT * FROM ${schema}.${name}`);
|
|
128
|
+
const counts = db.prepare(`SELECT (SELECT count(*) FROM main.${name}) AS actual,
|
|
129
|
+
(SELECT count(*) FROM ${schema}.${name}) AS expected`).get();
|
|
130
|
+
const different = db.prepare(`SELECT * FROM ${schema}.${name} EXCEPT SELECT * FROM main.${name}`).get();
|
|
131
|
+
const extra = db.prepare(`SELECT * FROM main.${name} EXCEPT SELECT * FROM ${schema}.${name}`).get();
|
|
132
|
+
if (counts?.actual !== counts?.expected || different || extra)
|
|
133
|
+
throw new Error(`Legacy ${source.component} copy verification failed`);
|
|
134
|
+
}
|
|
135
|
+
for (const index of objects.filter(object => object.type === "index"))
|
|
136
|
+
db.exec(String(index.sql));
|
|
137
|
+
if (source.component === "people")
|
|
138
|
+
db.prepare("INSERT INTO memory_schema VALUES ('people',?)").run(version);
|
|
139
|
+
}
|
|
140
|
+
if (db.prepare("PRAGMA main.foreign_key_check").get())
|
|
141
|
+
throw new Error("Migrated memory has broken foreign keys");
|
|
142
|
+
const integrity = db.prepare("PRAGMA main.integrity_check").all();
|
|
143
|
+
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok")
|
|
144
|
+
throw new Error("Migrated memory integrity check failed");
|
|
145
|
+
db.prepare("INSERT INTO memory_schema VALUES ('storage',1)").run();
|
|
146
|
+
}
|
|
147
|
+
db.exec("COMMIT");
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
db.exec("ROLLBACK");
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
for (const alias of attached.reverse())
|
|
159
|
+
db.exec(`DETACH DATABASE ${identifier(alias)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** File existence no longer tells us which feature has initialized its tables. */
|
|
163
|
+
export function hasMemoryTable(path, table) {
|
|
164
|
+
if (!existsSync(path) && !(basename(path) === MEMORY_DATABASE &&
|
|
165
|
+
legacyStores.some(source => existsSync(join(dirname(path), source.file)))))
|
|
166
|
+
return false;
|
|
167
|
+
const db = openMemoryDatabase(path);
|
|
168
|
+
try {
|
|
169
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table));
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/src/people-store.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { MEMORY_DATABASE, openMemoryDatabase } from "./memory-database.js";
|
|
6
5
|
import { resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
|
7
6
|
import { normalizeAgentIdStrict } from "openclaw/plugin-sdk/routing";
|
|
8
7
|
import { Type } from "typebox";
|
|
@@ -138,13 +137,10 @@ export class PeopleStore {
|
|
|
138
137
|
#maxOpenTodos;
|
|
139
138
|
#maxBlurbChars;
|
|
140
139
|
constructor(path, options) {
|
|
141
|
-
|
|
142
|
-
this.#db = new DatabaseSync(path);
|
|
140
|
+
this.#db = openMemoryDatabase(path);
|
|
143
141
|
this.#maxOpenTodos = options.maxOpenTodos;
|
|
144
142
|
this.#maxBlurbChars = options.maxBlurbChars;
|
|
145
143
|
try {
|
|
146
|
-
chmodSync(path, 0o600);
|
|
147
|
-
this.#db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON");
|
|
148
144
|
this.#migrate();
|
|
149
145
|
}
|
|
150
146
|
catch (error) {
|
|
@@ -740,23 +736,24 @@ export class PeopleStore {
|
|
|
740
736
|
}
|
|
741
737
|
}
|
|
742
738
|
#migrate() {
|
|
743
|
-
const current = this.#db.prepare("PRAGMA user_version").get();
|
|
744
|
-
if (current.user_version === 4)
|
|
745
|
-
return;
|
|
746
|
-
if (current.user_version !== 0 &&
|
|
747
|
-
current.user_version !== 1 &&
|
|
748
|
-
current.user_version !== 2 &&
|
|
749
|
-
current.user_version !== 3) {
|
|
750
|
-
throw new Error(`unsupported PeopleSQL schema version: ${current.user_version}`);
|
|
751
|
-
}
|
|
752
739
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
753
740
|
try {
|
|
754
|
-
|
|
741
|
+
// Re-read under the write lock so concurrent first opens cannot both migrate.
|
|
742
|
+
const version = this.#db.prepare("SELECT version FROM memory_schema WHERE component='people'").get()?.version
|
|
743
|
+
?? this.#db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
744
|
+
if (![0, 1, 2, 3, 4].includes(Number(version)))
|
|
745
|
+
throw new Error(`unsupported PeopleSQL schema version: ${version}`);
|
|
746
|
+
if (version === 4) {
|
|
747
|
+
this.#db.prepare("INSERT OR IGNORE INTO memory_schema VALUES ('people',4)").run();
|
|
748
|
+
this.#db.exec("COMMIT");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (version === 2) {
|
|
755
752
|
this.#db.exec(`
|
|
756
753
|
DROP TABLE person_evidence_receipts;
|
|
757
754
|
`);
|
|
758
755
|
}
|
|
759
|
-
else if (
|
|
756
|
+
else if (version === 1) {
|
|
760
757
|
this.#db.exec(`
|
|
761
758
|
DROP INDEX people_policy_seen;
|
|
762
759
|
ALTER TABLE people DROP COLUMN refinement_enabled;
|
|
@@ -772,7 +769,7 @@ export class PeopleStore {
|
|
|
772
769
|
) STRICT;
|
|
773
770
|
`);
|
|
774
771
|
}
|
|
775
|
-
else if (
|
|
772
|
+
else if (version === 0) {
|
|
776
773
|
this.#db.exec(`
|
|
777
774
|
CREATE TABLE companies (
|
|
778
775
|
id TEXT PRIMARY KEY,
|
|
@@ -859,7 +856,8 @@ export class PeopleStore {
|
|
|
859
856
|
|
|
860
857
|
CREATE INDEX person_dossier_changes_person_changed
|
|
861
858
|
ON person_dossier_changes(person_id, changed_at DESC);
|
|
862
|
-
|
|
859
|
+
INSERT INTO memory_schema VALUES ('people',4)
|
|
860
|
+
ON CONFLICT(component) DO UPDATE SET version=excluded.version;
|
|
863
861
|
`);
|
|
864
862
|
this.#db.exec("COMMIT");
|
|
865
863
|
}
|
|
@@ -884,7 +882,7 @@ export class PeopleStores {
|
|
|
884
882
|
const canonicalAgentId = normalized.value;
|
|
885
883
|
let store = this.#stores.get(canonicalAgentId);
|
|
886
884
|
if (!store) {
|
|
887
|
-
store = new PeopleStore(join(this.#stateRoot, "agents", canonicalAgentId, "unblock-memory",
|
|
885
|
+
store = new PeopleStore(join(this.#stateRoot, "agents", canonicalAgentId, "unblock-memory", MEMORY_DATABASE), this.#options);
|
|
888
886
|
this.#stores.set(canonicalAgentId, store);
|
|
889
887
|
}
|
|
890
888
|
return store;
|