@unblocklabs/unblock-memory 0.3.18 → 0.3.20

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
@@ -154,8 +154,9 @@ Choice confidence from Noul yes-probability. Multiple supported reasons can coex
154
154
  `reportVersion` identifies composition/reporting semantics independently of the
155
155
  judge rubric, allowing cached judgments to be re-reported without re-inference.
156
156
 
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
157
+ Results live in operator-only tables in the agent's private
158
+ `unblock-memory/unblock-memory.sqlite`, outside the memory index. These tables
159
+ are not searched or injected into agent prompts. They store judgments and source event references/hashes, not copies
159
160
  of conversations. Identical successful inputs are cached; source rewrites invalidate
160
161
  in-scope results on the next scan. Reports partition by fixed judge/rubric/context
161
162
  configuration, UTC week, task type and agent model. They expose eligible/assessed
@@ -599,7 +600,7 @@ For optional read-only diagnostics, `memory_people_prime({ personId, agentName?,
599
600
  draft: { blurb, citations: [{ path, from, lines }] } })` still reviews a snippet
600
601
  without writing. Agents do not need this extra call in the normal update workflow.
601
602
 
602
- Judgments are cached privately in `people.sqlite` (maximum 2,000 entries), keyed
603
+ Judgments are cached privately in `unblock-memory.sqlite` (maximum 2,000 entries), keyed
603
604
  by person, agent, exact evidence/context, questions,
604
605
  and judge version. No source text or credentials are stored in the cache.
605
606
  Retrieval reruns against the current index; unchanged judgments are reused.
@@ -758,10 +759,38 @@ explicit intervals remain unchanged on upgrade; set them to `60` for hourly chec
758
759
 
759
760
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
760
761
  equivalent configured OpenClaw state directory). Durable agent-supplied event
761
- dates and maintenance proposals live separately in `curation.sqlite`, so a QMD
762
+ dates, maintenance proposals, people/dossiers and response audits live separately
763
+ in `unblock-memory.sqlite`, so a QMD
762
764
  index rebuild does not discard them. The first lookup builds the index;
763
765
  Markdown filesystem changes queue a debounced, serialized background refresh.
764
766
 
767
+ ### Durable database migration (v0.3.20)
768
+
769
+ Each agent has two active plugin databases: rebuildable `index.sqlite` and durable
770
+ `unblock-memory.sqlite`. The latter uses WAL, private permissions and component
771
+ schema versions. Store modules and tool access remain separate: consolidating files
772
+ does not expose operator response audits to memory searches or whisperers.
773
+
774
+ **Stop the Gateway and any plugin CLI writers before upgrading.** On first
775
+ durable-store access, the plugin imports existing `curation.sqlite`, `people.sqlite`
776
+ and `response-audit.sqlite` files, including disabled features, in one transaction.
777
+ It includes committed WAL data, verifies row counts/values, integrity and foreign
778
+ keys, and records completion. Missing stores are normal. Unsupported or invalid
779
+ data aborts the import without a partial cutover; the next access retries.
780
+ QMD and transcript databases are not migrated.
781
+
782
+ Old files remain untouched as **inert recovery copies**, not active stores.
783
+ Completed migration never reimports or writes to them. Do not run old and new
784
+ plugin versions together: old writers can keep changing their separate files.
785
+ Back up the new database with SQLite's online backup API, or stop all writers
786
+ and safely checkpoint WAL first. Copying only a live `.sqlite` file is unsafe.
787
+
788
+ To roll back before any new writes, stop all writers, preserve the new database
789
+ and its WAL/SHM sidecars, and restore the old plugin against the retained files.
790
+ **After new writes, the legacy files are stale:** rollback requires an explicit
791
+ reverse data migration or accepting the loss of post-upgrade changes.
792
+ Retain recovery files until the upgrade is verified; cleanup is a separate step.
793
+
765
794
  ## Memory quality audit
766
795
 
767
796
  `memory_audit_quality` is an on-demand, source-read-only audit. TypeSafe flags likely
@@ -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
@@ -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,157 @@
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
+ /** Separate domain stores share settings, not a monolithic data-access API. */
26
+ export function openMemoryDatabase(path) {
27
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
28
+ requireRegularFile(path);
29
+ const descriptor = openSync(path, constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
30
+ try {
31
+ fchmodSync(descriptor, 0o600);
32
+ }
33
+ finally {
34
+ closeSync(descriptor);
35
+ }
36
+ const db = new DatabaseSync(path);
37
+ try {
38
+ db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
39
+ PRAGMA trusted_schema=OFF;
40
+ CREATE TABLE IF NOT EXISTS memory_schema (component TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;`);
41
+ // Explicit standalone store paths remain useful to tests and offline tools.
42
+ // Only the production filename opts into sibling-file consolidation.
43
+ if (basename(path) === MEMORY_DATABASE)
44
+ consolidate(db, dirname(path));
45
+ return db;
46
+ }
47
+ catch (error) {
48
+ db.close();
49
+ throw error;
50
+ }
51
+ }
52
+ function consolidate(db, directory) {
53
+ const completed = () => {
54
+ const version = db.prepare("SELECT version FROM memory_schema WHERE component='storage'").get()?.version;
55
+ if (version !== undefined && version !== 1)
56
+ throw new Error("Unsupported durable storage version");
57
+ return version === 1;
58
+ };
59
+ if (completed())
60
+ return;
61
+ const sources = legacyStores.filter(source => lstatSync(join(directory, source.file), { throwIfNoEntry: false }));
62
+ const attached = [];
63
+ try {
64
+ for (const source of sources) {
65
+ const path = join(directory, source.file);
66
+ requireRegularFile(path);
67
+ const alias = `legacy_${source.component}`;
68
+ db.prepare(`ATTACH DATABASE ? AS ${identifier(alias)}`).run(path);
69
+ attached.push(alias);
70
+ }
71
+ // Attached legacy writer locks cover the entire snapshot/copy/verification.
72
+ // They cannot stop old binaries writing again later: upgrade with writers stopped.
73
+ db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
74
+ try {
75
+ if (!completed()) {
76
+ const existing = db.prepare("SELECT name FROM main.sqlite_schema WHERE type='table' AND name!='memory_schema'").get();
77
+ if (existing)
78
+ throw new Error("Unmarked durable database is not empty; refusing to overwrite it");
79
+ for (const source of sources) {
80
+ const schema = identifier(`legacy_${source.component}`);
81
+ const version = Number(db.prepare(`PRAGMA ${schema}.user_version`).get()?.user_version);
82
+ if (source.component === "people" ? version < 0 || version > 4 : version !== 0) {
83
+ throw new Error(`Unsupported legacy ${source.component} schema version`);
84
+ }
85
+ const integrity = db.prepare(`PRAGMA ${schema}.integrity_check`).all();
86
+ if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok")
87
+ throw new Error(`Invalid legacy ${source.component} database`);
88
+ const objects = db.prepare(`SELECT type,name,tbl_name,sql FROM ${schema}.sqlite_schema WHERE name NOT LIKE 'sqlite_%'`).all();
89
+ if (source.component === "people" && version === 0 && objects.length)
90
+ throw new Error("Unversioned legacy people schema");
91
+ for (const object of objects) {
92
+ if ((object.type !== "table" && object.type !== "index") || typeof object.sql !== "string" ||
93
+ !source.tables.some(name => name === object.tbl_name) || /CREATE\s+VIRTUAL\s+TABLE/i.test(object.sql)) {
94
+ throw new Error(`Unsupported legacy ${source.component} schema object`);
95
+ }
96
+ }
97
+ const tables = objects.filter(object => object.type === "table");
98
+ if (source.component === "people" && version > 0) {
99
+ const required = ["companies", "people", "person_identities", "person_dossiers", "people_todos",
100
+ ...(version >= 2 ? ["person_whisper_receipts"] : []),
101
+ ...(version === 2 ? ["person_evidence_receipts"] : []),
102
+ ...(version === 4 ? ["person_dossier_changes"] : [])];
103
+ if (required.some(name => !tables.some(table => table.name === name)))
104
+ throw new Error("Incomplete legacy people schema");
105
+ }
106
+ for (const table of tables)
107
+ db.exec(String(table.sql));
108
+ for (const table of tables) {
109
+ const name = identifier(String(table.name));
110
+ db.exec(`INSERT INTO main.${name} SELECT * FROM ${schema}.${name}`);
111
+ const counts = db.prepare(`SELECT (SELECT count(*) FROM main.${name}) AS actual,
112
+ (SELECT count(*) FROM ${schema}.${name}) AS expected`).get();
113
+ const different = db.prepare(`SELECT * FROM ${schema}.${name} EXCEPT SELECT * FROM main.${name}`).get();
114
+ const extra = db.prepare(`SELECT * FROM main.${name} EXCEPT SELECT * FROM ${schema}.${name}`).get();
115
+ if (counts?.actual !== counts?.expected || different || extra)
116
+ throw new Error(`Legacy ${source.component} copy verification failed`);
117
+ }
118
+ for (const index of objects.filter(object => object.type === "index"))
119
+ db.exec(String(index.sql));
120
+ if (source.component === "people")
121
+ db.prepare("INSERT INTO memory_schema VALUES ('people',?)").run(version);
122
+ }
123
+ if (db.prepare("PRAGMA main.foreign_key_check").get())
124
+ throw new Error("Migrated memory has broken foreign keys");
125
+ const integrity = db.prepare("PRAGMA main.integrity_check").all();
126
+ if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok")
127
+ throw new Error("Migrated memory integrity check failed");
128
+ db.prepare("INSERT INTO memory_schema VALUES ('storage',1)").run();
129
+ }
130
+ db.exec("COMMIT");
131
+ }
132
+ catch (error) {
133
+ db.exec("ROLLBACK");
134
+ throw error;
135
+ }
136
+ finally {
137
+ db.exec("PRAGMA foreign_keys=ON");
138
+ }
139
+ }
140
+ finally {
141
+ for (const alias of attached.reverse())
142
+ db.exec(`DETACH DATABASE ${identifier(alias)}`);
143
+ }
144
+ }
145
+ /** File existence no longer tells us which feature has initialized its tables. */
146
+ export function hasMemoryTable(path, table) {
147
+ if (!existsSync(path) && !(basename(path) === MEMORY_DATABASE &&
148
+ legacyStores.some(source => existsSync(join(dirname(path), source.file)))))
149
+ return false;
150
+ const db = openMemoryDatabase(path);
151
+ try {
152
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table));
153
+ }
154
+ finally {
155
+ db.close();
156
+ }
157
+ }
@@ -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;
@@ -35,7 +35,7 @@ export async function auditResponses(options) {
35
35
  return { status: "unavailable", reason: "TypeSafe API key not configured" };
36
36
  }
37
37
  let reader, store, lease;
38
- const people = new ResponsePeople(options.peoplePath);
38
+ let people;
39
39
  const now = Date.now();
40
40
  const coverage = { sessions: 0, sessionLimitReached: false, sessionsOverBudget: 0, completedResponses: 0,
41
41
  reconciledSessions: 0, reconciliationDeferred: 0,
@@ -51,6 +51,8 @@ export async function auditResponses(options) {
51
51
  if (!lease)
52
52
  return { status: "already_running" };
53
53
  }
54
+ // The first store open may have imported people into the shared database.
55
+ people = new ResponsePeople(options.peoplePath);
54
56
  reader = new ResponseTranscriptReader(options.databasePath, agentId);
55
57
  const since = now - config.responseAudit.lookbackDays * 86400_000;
56
58
  store?.reviews.refresh(cohort, since);
@@ -184,7 +186,7 @@ export async function auditResponses(options) {
184
186
  if (lease)
185
187
  store?.release(lease);
186
188
  store?.close();
187
- people.close();
189
+ people?.close();
188
190
  }
189
191
  }
190
192
  function references(e) {
@@ -1,9 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
2
3
  import { DatabaseSync } from "node:sqlite";
4
+ import { MEMORY_DATABASE } from "./memory-database.js";
3
5
  /** Identity is trusted metadata, never inferred from names or transcript text. */
4
6
  export class ResponsePeople {
5
7
  #db;
6
8
  constructor(path) {
9
+ // Dry-run audits must not migrate or create stores just to resolve identities.
10
+ if (path && basename(path) === MEMORY_DATABASE && !existsSync(path))
11
+ path = join(dirname(path), "people.sqlite");
7
12
  if (!path || !existsSync(path))
8
13
  return;
9
14
  try {
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { hasMemoryTable, MEMORY_DATABASE } from "./memory-database.js";
2
2
  import { join } from "node:path";
3
3
  import { resolveAgentDir, resolveAgentWorkspaceDir, resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
4
4
  import { listAgentIds } from "openclaw/plugin-sdk/agent-runtime";
@@ -22,8 +22,8 @@ export function registerResponseAudit(api, config) {
22
22
  const agentId = normalized.value;
23
23
  const state = join(resolveStateDir(), "agents", agentId, "unblock-memory");
24
24
  return { agentId, config, databasePath: join(resolveAgentDir(cfg, agentId), "openclaw-agent.sqlite"),
25
- storePath: join(state, "response-audit.sqlite"), indexPath: join(state, "index.sqlite"),
26
- peoplePath: join(state, "people.sqlite"),
25
+ storePath: join(state, MEMORY_DATABASE), indexPath: join(state, "index.sqlite"),
26
+ peoplePath: join(state, MEMORY_DATABASE),
27
27
  sources: resolveSources(resolveAgentWorkspaceDir(cfg, agentId), config.corpora.filter(c => c.kind === "files")
28
28
  .filter(c => config.responseAudit.memoryCorpora.includes(c.name))) };
29
29
  };
@@ -47,7 +47,7 @@ export function registerResponseAudit(api, config) {
47
47
  return;
48
48
  }
49
49
  const { storePath } = options(cfg, opts.agent);
50
- if (!existsSync(storePath)) {
50
+ if (!hasMemoryTable(storePath, "response_results")) {
51
51
  console.log(JSON.stringify({ status: "not_run" }));
52
52
  return;
53
53
  }
@@ -68,7 +68,7 @@ export function registerResponseAudit(api, config) {
68
68
  if (!config.responseAudit.enabled)
69
69
  throw new Error("Response audit is disabled");
70
70
  const { storePath } = options(cfg, agent);
71
- if (!existsSync(storePath))
71
+ if (!hasMemoryTable(storePath, "response_results"))
72
72
  throw new Error("Response audit has not run");
73
73
  const store = new ResponseAuditStore(storePath);
74
74
  try {
@@ -31,7 +31,7 @@ export type ResponseReportOptions = {
31
31
  taskType?: string;
32
32
  agentModel?: string;
33
33
  };
34
- /** Separate operator-only database: not a memory corpus and never injected into agent prompts. */
34
+ /** Operator-only tables: not a memory corpus and never injected into agent prompts. */
35
35
  export declare class ResponseAuditStore {
36
36
  #private;
37
37
  readonly reviews: ResponseReviews;
@@ -1,40 +1,47 @@
1
1
  import { 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
  import { responseOutcome, RESPONSE_REPORT_VERSION } from "./response-outcome.js";
6
4
  import { ResponsePeople } from "./response-identity.js";
7
5
  import { ResponseReviews, RESPONSE_REVIEW_POLICY } from "./response-reviews.js";
8
- /** Separate operator-only database: not a memory corpus and never injected into agent prompts. */
6
+ /** Operator-only tables: not a memory corpus and never injected into agent prompts. */
9
7
  export class ResponseAuditStore {
10
8
  #db;
11
9
  reviews;
12
10
  constructor(path) {
13
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
14
- this.#db = new DatabaseSync(path);
15
- chmodSync(path, 0o600);
16
- this.#db.exec(`PRAGMA busy_timeout=1000;
17
- CREATE TABLE IF NOT EXISTS response_lease (id INTEGER PRIMARY KEY CHECK(id=1), token TEXT, expires INTEGER);
18
- CREATE TABLE IF NOT EXISTS response_results (
19
- cohort TEXT, id TEXT, session_id TEXT NOT NULL, input_hash TEXT NOT NULL,
20
- episode_at INTEGER NOT NULL, active INTEGER NOT NULL, status TEXT NOT NULL,
21
- attempts INTEGER NOT NULL DEFAULT 0, attempted_at INTEGER, assessed_at INTEGER, result TEXT,
22
- PRIMARY KEY(cohort,id));
23
- CREATE TABLE IF NOT EXISTS response_scans (cohort TEXT PRIMARY KEY, observed_at INTEGER, coverage TEXT);
24
- CREATE INDEX IF NOT EXISTS response_results_time ON response_results(cohort,episode_at);`);
25
- this.#db.exec(`CREATE TABLE IF NOT EXISTS response_checkpoints (
26
- cohort TEXT NOT NULL, session_id TEXT NOT NULL, revision TEXT NOT NULL, coverage TEXT NOT NULL,
27
- PRIMARY KEY(cohort,session_id));
28
- CREATE TABLE IF NOT EXISTS response_cursors (cohort TEXT PRIMARY KEY,cursor TEXT NOT NULL);
29
- CREATE TABLE IF NOT EXISTS response_schedule (
30
- id INTEGER PRIMARY KEY CHECK(id=1), interval_ms INTEGER NOT NULL, next_due INTEGER NOT NULL);
31
- CREATE TABLE IF NOT EXISTS response_stages (
32
- key TEXT PRIMARY KEY, stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
33
- attempts INTEGER NOT NULL DEFAULT 0, attempted_at INTEGER, assessed_at INTEGER, result TEXT);
34
- CREATE TABLE IF NOT EXISTS response_stage_links (
35
- cohort TEXT NOT NULL, episode_id TEXT NOT NULL, input_hash TEXT NOT NULL, stage TEXT NOT NULL, key TEXT NOT NULL,
36
- PRIMARY KEY(cohort,episode_id,stage));`);
37
- this.reviews = new ResponseReviews(this.#db);
11
+ this.#db = openMemoryDatabase(path);
12
+ try {
13
+ this.#db.exec("BEGIN IMMEDIATE");
14
+ const version = this.#db.prepare("SELECT version FROM memory_schema WHERE component='responses'").get()?.version;
15
+ if (version !== undefined && version !== 1)
16
+ throw new Error("Unsupported response audit schema version");
17
+ this.#db.exec(`
18
+ CREATE TABLE IF NOT EXISTS response_lease (id INTEGER PRIMARY KEY CHECK(id=1), token TEXT, expires INTEGER);
19
+ CREATE TABLE IF NOT EXISTS response_results (
20
+ cohort TEXT, id TEXT, session_id TEXT NOT NULL, input_hash TEXT NOT NULL,
21
+ episode_at INTEGER NOT NULL, active INTEGER NOT NULL, status TEXT NOT NULL,
22
+ attempts INTEGER NOT NULL DEFAULT 0, attempted_at INTEGER, assessed_at INTEGER, result TEXT,
23
+ PRIMARY KEY(cohort,id));
24
+ CREATE TABLE IF NOT EXISTS response_scans (cohort TEXT PRIMARY KEY, observed_at INTEGER, coverage TEXT);
25
+ CREATE INDEX IF NOT EXISTS response_results_time ON response_results(cohort,episode_at);`);
26
+ this.#db.exec(`CREATE TABLE IF NOT EXISTS response_checkpoints (
27
+ cohort TEXT NOT NULL, session_id TEXT NOT NULL, revision TEXT NOT NULL, coverage TEXT NOT NULL,
28
+ PRIMARY KEY(cohort,session_id));
29
+ CREATE TABLE IF NOT EXISTS response_cursors (cohort TEXT PRIMARY KEY,cursor TEXT NOT NULL);
30
+ CREATE TABLE IF NOT EXISTS response_schedule (
31
+ id INTEGER PRIMARY KEY CHECK(id=1), interval_ms INTEGER NOT NULL, next_due INTEGER NOT NULL);
32
+ CREATE TABLE IF NOT EXISTS response_stages (
33
+ key TEXT PRIMARY KEY, stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
34
+ attempts INTEGER NOT NULL DEFAULT 0, attempted_at INTEGER, assessed_at INTEGER, result TEXT);
35
+ CREATE TABLE IF NOT EXISTS response_stage_links (
36
+ cohort TEXT NOT NULL, episode_id TEXT NOT NULL, input_hash TEXT NOT NULL, stage TEXT NOT NULL, key TEXT NOT NULL,
37
+ PRIMARY KEY(cohort,episode_id,stage));`);
38
+ this.reviews = new ResponseReviews(this.#db);
39
+ this.#db.exec("INSERT OR IGNORE INTO memory_schema VALUES ('responses',1); COMMIT");
40
+ }
41
+ catch (error) {
42
+ this.#db.close();
43
+ throw error;
44
+ }
38
45
  }
39
46
  /** Claim one bounded scheduled attempt, never replay every missed interval. */
40
47
  claimScheduled(now, intervalMs) {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { MEMORY_DATABASE } from "./memory-database.js";
3
4
  import { join } from "node:path";
4
5
  import { resolveAgentDir, resolveAgentWorkspaceDir, resolveStateDir, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
5
6
  import { listAgentIds, resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
@@ -240,7 +241,7 @@ export class QmdMemoryRuntime {
240
241
  const manager = new QmdMemoryManager({
241
242
  workspaceDir,
242
243
  dbPath: join(stateDir, "index.sqlite"),
243
- curationPath: join(stateDir, "curation.sqlite"),
244
+ curationPath: join(stateDir, MEMORY_DATABASE),
244
245
  sources,
245
246
  keepModelsWarm: this.#keepEmbeddingModelWarm,
246
247
  analysisExecutable: this.#analysisExecutable,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.18",
4
+ "version": "0.3.20",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "preflight": "npm run knip && npm run build && npm run typecheck && npm test && npm run plugin:inspect && npm run plugin:inspect:runtime && npm pack --dry-run"
36
36
  },
37
37
  "dependencies": {
38
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.6/unblocklabs-qmd-2.9.6.tgz",
38
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.10.0/unblocklabs-qmd-2.10.0.tgz",
39
39
  "chokidar": "5.0.0",
40
40
  "picomatch": "^4.0.5",
41
41
  "typebox": "1.3.6"