@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 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 `unblock-memory/response-audit.sqlite`, outside
158
- the memory index. It stores judgments and source event references/hashes, not copies
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 `people.sqlite` (maximum 2,000 entries), keyed
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 and maintenance proposals live separately in `curation.sqlite`, so a QMD
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
@@ -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: {
@@ -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),
@@ -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 vector-hint budget; oversized matched chunks are omitted, never sliced. */
27
+ /** Internal hint/reranking budget; oversized matched chunks are omitted, never sliced. */
28
28
  maxSnippetChars?: number;
29
29
  sessionFilter?: SessionSearchFilter;
30
30
  requestContext?: MemoryRequestContext;
@@ -1,7 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, mkdirSync } from "node:fs";
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
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
- this.#db = new DatabaseSync(path);
47
- chmodSync(path, 0o600);
48
- this.#db.exec(`
49
- PRAGMA journal_mode = WAL;
50
- PRAGMA busy_timeout = 5000;
51
-
52
- CREATE TABLE IF NOT EXISTS temporal_annotations (
53
- corpus TEXT NOT NULL,
54
- collection TEXT NOT NULL,
55
- path TEXT NOT NULL,
56
- content_fingerprint TEXT NOT NULL DEFAULT '',
57
- event_time TEXT NOT NULL,
58
- basis TEXT NOT NULL CHECK (basis IN ('path', 'frontmatter', 'session', 'agent_verified')),
59
- evidence TEXT NOT NULL,
60
- qmd_hash TEXT,
61
- qmd_seq INTEGER,
62
- created_at TEXT NOT NULL,
63
- updated_at TEXT NOT NULL,
64
- PRIMARY KEY (corpus, collection, path, content_fingerprint)
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
- this.#ensureMaintenanceSchema();
69
- this.#db.exec(`CREATE TABLE IF NOT EXISTS quality_judgments (
70
- cache_key TEXT PRIMARY KEY,
71
- noise REAL NOT NULL CHECK (noise BETWEEN 0 AND 1),
72
- evidence REAL NOT NULL CHECK (evidence BETWEEN 0 AND 1)
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("BEGIN IMMEDIATE");
97
- try {
98
- this.#db.exec(schema.sql.replace("maintenance_tasks", "maintenance_tasks_quality")
99
- .replace("'exact_duplicate'", "'exact_duplicate', 'quality_review'"));
100
- this.#db.exec(`INSERT INTO maintenance_tasks_quality SELECT * FROM maintenance_tasks;
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
@@ -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;
@@ -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
- // Proactive hints must retain the entire matched chunk, even when expanded
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
+ }
@@ -1,8 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { randomUUID } from "node:crypto";
3
- import { chmodSync, mkdirSync } from "node:fs";
4
- import { join, dirname } from "node:path";
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
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
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
- if (current.user_version === 2) {
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 (current.user_version === 1) {
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 (current.user_version === 0) {
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
- PRAGMA user_version = 4;
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", "people.sqlite"), this.#options);
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;