@unblocklabs/unblock-memory 0.10.0 → 0.10.1

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.
@@ -4,6 +4,8 @@ export declare const INSIDE_OUT_EMOTIONS: readonly ["joy", "sadness", "fear", "a
4
4
  export declare const PURGED_GRADE = "Purged by operator";
5
5
  type Emotion = typeof INSIDE_OUT_EMOTIONS[number];
6
6
  type Row = Record<string, SQLOutputValue>;
7
+ export declare const hash: (value: unknown) => string;
8
+ export declare const tableExists: (db: DatabaseSync, name: string) => boolean;
7
9
  type Statistics = {
8
10
  count: number;
9
11
  mean: number | null;
@@ -89,7 +91,10 @@ export declare function deviationReader(db: DatabaseSync): (row: Row, history?:
89
91
  }[];
90
92
  status: "not_compared";
91
93
  };
92
- /** Weekly snapshots, newest first; cumulative statistics are prefix sums of each population's stored weeks. */
94
+ /**
95
+ * Weekly snapshots, newest first; cumulative statistics are prefix sums of each population's stored weeks.
96
+ * `since` keeps weeks whose last day is on or after that UTC date (`week_end` is the exclusive next Monday).
97
+ */
93
98
  export declare function emotionBaselineHistory(db: DatabaseSync, options?: {
94
99
  personId?: string;
95
100
  since?: number;
@@ -106,6 +111,14 @@ export declare function emotionBaselineHistory(db: DatabaseSync, options?: {
106
111
  weekly: Summary;
107
112
  cumulative: Summary;
108
113
  }[];
114
+ /**
115
+ * Changes whenever a refresh input could: grades, links, people, stored results, the current week,
116
+ * grades crossing `now`, or settings. Equal fingerprints mean a refresh would write nothing.
117
+ */
118
+ export declare function baselineFingerprint(db: DatabaseSync, options: {
119
+ minInteractions: number;
120
+ now: number;
121
+ }): string;
109
122
  /**
110
123
  * Delete one person's baselines and assessment history. With `grades`, also erase their six
111
124
  * probabilities and person link, keeping a tombstone so the reply is never sent to Jev again.
@@ -10,10 +10,11 @@ const STEPS = 100;
10
10
  // Writers on the shared store wait for at most one short batch, never a whole refresh.
11
11
  const BATCH = 500;
12
12
  const PAGE = 2000;
13
- const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
13
+ export const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
14
14
  const weekStart = (time) => MONDAY + Math.floor((time - MONDAY) / WEEK) * WEEK;
15
15
  const bin = (value) => Math.round(value * STEPS);
16
- const tableExists = (db, name) => Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(name));
16
+ export const tableExists = (db, name) => Boolean(db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(name));
17
+ const GRADE = `interaction_id,rubric,model,person_id,target_at,reviewed_at,error,${INSIDE_OUT_EMOTIONS.join(",")}`;
17
18
  const VALID = INSIDE_OUT_EMOTIONS.map(e => `e.${e} BETWEEN 0 AND 1`).join(" AND ");
18
19
  // Comparable grades: successful, timed, not after `now`. Callers add the People join.
19
20
  const COMPARABLE = `e.reviewed_at<=? AND e.error IS NULL AND e.target_at IS NOT NULL AND e.target_at<=? AND ${VALID}`;
@@ -186,12 +187,14 @@ export function refreshEmotionBaselines(db, options) {
186
187
  initializeEmotionBaselines(db);
187
188
  let snapshots = 0, comparisons = 0;
188
189
  const queue = batches(db), people = tableExists(db, "people");
189
- const prior = db.prepare(`SELECT id,result_json FROM inside_out_deviations WHERE interaction_id=? AND rubric=? AND grade_signature=?
190
- ORDER BY assessed_at DESC,rowid DESC LIMIT 1`);
190
+ const prior = db.prepare(`SELECT id,result_json,json_extract(result_json,'$.status') status FROM inside_out_deviations
191
+ WHERE interaction_id=? AND rubric=? AND grade_signature=? ORDER BY assessed_at DESC,rowid DESC LIMIT 1`);
191
192
  const save = db.prepare("INSERT OR IGNORE INTO inside_out_deviations VALUES (?,?,?,?,?,?,?,?)");
193
+ const grade = db.prepare(`SELECT ${GRADE} FROM inside_out WHERE interaction_id=? AND rubric=?`);
192
194
  const assess = (row, signature, decide) => {
193
195
  const previous = prior.get(row.interaction_id, row.rubric, signature);
194
- if (!options.recompare && previous)
196
+ // An existing assessment stands for its grade, except future_time: that only described the clock.
197
+ if (!options.recompare && previous && previous.status !== "future_time")
195
198
  return;
196
199
  const result = { version: VERSION, status: "insufficient_history", personId: row.person_id === null ? null : String(row.person_id),
197
200
  baselineId: null, baselineEnd: null, assessedAt: now, minInteractions: minimum, cumulative: null, weekly: null };
@@ -203,6 +206,10 @@ export function refreshEmotionBaselines(db, options) {
203
206
  // selecting an obsolete intervening assessment or losing either observation.
204
207
  const id = hash([key(result), previous?.id ?? null]);
205
208
  queue.write(() => {
209
+ // Rechecked under the write lock: a purge, regrade or relink committed since the read wins.
210
+ const current = grade.get(row.interaction_id, row.rubric);
211
+ if (!current || gradeSignature(current) !== signature)
212
+ return;
206
213
  comparisons += Number(save.run(id, row.interaction_id, row.rubric, signature, now, result.baselineId, result.personId, JSON.stringify(result)).changes);
207
214
  });
208
215
  };
@@ -213,8 +220,8 @@ export function refreshEmotionBaselines(db, options) {
213
220
  ON CONFLICT(person_id,rubric,model,week_start) DO UPDATE SET id=excluded.id,version=excluded.version,
214
221
  week_end=excluded.week_end,computed_at=excluded.computed_at,weekly_json=excluded.weekly_json`);
215
222
  const remove = db.prepare("DELETE FROM person_emotion_baselines WHERE person_id=? AND rubric=? AND model=? AND week_start=? AND id=?");
216
- const page = db.prepare(`SELECT interaction_id,rubric,model,person_id,target_at,reviewed_at,error,${INSIDE_OUT_EMOTIONS.join(",")}
217
- FROM inside_out e WHERE e.person_id=? AND e.rubric=? AND e.model=? AND ${COMPARABLE}
223
+ const populated = db.prepare("SELECT 1 FROM inside_out WHERE person_id=? AND rubric=? AND model=? LIMIT 1");
224
+ const page = db.prepare(`SELECT ${GRADE} FROM inside_out e WHERE e.person_id=? AND e.rubric=? AND e.model=? AND ${COMPARABLE}
218
225
  AND (? IS NULL OR e.target_at>? OR (e.target_at=? AND e.interaction_id>?)) ORDER BY e.target_at,e.interaction_id LIMIT ?`);
219
226
  const groups = db.prepare(`SELECT DISTINCT e.person_id,e.rubric,e.model FROM inside_out e JOIN people p ON p.id=e.person_id
220
227
  WHERE ${COMPARABLE}`).all(now, now);
@@ -266,7 +273,9 @@ export function refreshEmotionBaselines(db, options) {
266
273
  current.add(start);
267
274
  if (existing.get(start) !== id)
268
275
  queue.write(() => {
269
- snapshots += Number(upsert.run(person, rubric, model, start, id, VERSION, end, now, json).changes);
276
+ // A purge of this person since the read leaves no linked grades: never restore their distribution.
277
+ if (populated.get(person, rubric, model))
278
+ snapshots += Number(upsert.run(person, rubric, model, start, id, VERSION, end, now, json).changes);
270
279
  });
271
280
  previous = { id, end, weekly };
272
281
  }
@@ -325,15 +334,28 @@ export function deviationReader(db) {
325
334
  }) } : current;
326
335
  };
327
336
  }
328
- /** Weekly snapshots, newest first; cumulative statistics are prefix sums of each population's stored weeks. */
337
+ /**
338
+ * Weekly snapshots, newest first; cumulative statistics are prefix sums of each population's stored weeks.
339
+ * `since` keeps weeks whose last day is on or after that UTC date (`week_end` is the exclusive next Monday).
340
+ */
329
341
  export function emotionBaselineHistory(db, options = {}) {
330
342
  const limit = options.limit ?? 100;
331
343
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
332
344
  throw new Error("Baseline limit must be between 1 and 1000");
333
345
  if (schemaVersion(db) !== 2)
334
346
  return [];
347
+ // One read snapshot: a concurrent refresh may replace or drop weeks between the two queries.
348
+ db.exec("BEGIN");
349
+ try {
350
+ return readBaselineHistory(db, options, limit);
351
+ }
352
+ finally {
353
+ db.exec("COMMIT");
354
+ }
355
+ }
356
+ function readBaselineHistory(db, options, limit) {
335
357
  const selected = db.prepare(`SELECT person_id,rubric,model,week_start FROM person_emotion_baselines
336
- WHERE (? IS NULL OR person_id=?) AND (? IS NULL OR week_end>=?) ORDER BY week_end DESC,person_id,rubric,model LIMIT ?`)
358
+ WHERE (? IS NULL OR person_id=?) AND (? IS NULL OR week_end>?) ORDER BY week_end DESC,person_id,rubric,model LIMIT ?`)
337
359
  .all(options.personId ?? null, options.personId ?? null, options.since ?? null, options.since ?? null, limit);
338
360
  const wanted = new Map();
339
361
  for (const row of selected) {
@@ -358,6 +380,25 @@ export function emotionBaselineHistory(db, options = {}) {
358
380
  }
359
381
  return selected.map(row => snapshots.get(JSON.stringify([JSON.stringify([row.person_id, row.rubric, row.model]), row.week_start])));
360
382
  }
383
+ /**
384
+ * Changes whenever a refresh input could: grades, links, people, stored results, the current week,
385
+ * grades crossing `now`, or settings. Equal fingerprints mean a refresh would write nothing.
386
+ */
387
+ export function baselineFingerprint(db, options) {
388
+ const digest = createHash("sha256").update(JSON.stringify([VERSION, schemaVersion(db) ?? null, weekStart(options.now), options.minInteractions]));
389
+ for (const table of ["people", "person_emotion_baselines", "inside_out_deviations"]) {
390
+ digest.update(`|${tableExists(db, table) ? Number(db.prepare(`SELECT count(*) n FROM ${table}`).get().n) : -1}`);
391
+ }
392
+ if (tableExists(db, "inside_out")) {
393
+ const timing = db.prepare("SELECT count(*) n,sum(target_at<=?) due,sum(reviewed_at<=?) reviewed FROM inside_out").get(options.now, options.now);
394
+ digest.update(`|${timing.n},${timing.due},${timing.reviewed}`);
395
+ // Regrades rewrite reviewed_at; links and purges rewrite person_id and error.
396
+ for (const row of db.prepare("SELECT rowid,person_id,reviewed_at,error IS NULL ok FROM inside_out ORDER BY rowid").iterate()) {
397
+ digest.update(`|${row.rowid},${row.person_id},${row.reviewed_at},${row.ok}`);
398
+ }
399
+ }
400
+ return digest.digest("hex");
401
+ }
361
402
  /**
362
403
  * Delete one person's baselines and assessment history. With `grades`, also erase their six
363
404
  * probabilities and person link, keeping a tombstone so the reply is never sent to Jev again.
@@ -2,7 +2,10 @@ import type { DatabaseSync } from "node:sqlite";
2
2
  import { refreshEmotionBaselines } from "./emotion-baselines.js";
3
3
  export declare const PERSON_MATCH = "SELECT min(i.person_id) person_id FROM person_identities i\n WHERE i.provider=e.channel AND i.external_id=e.sender_id\n AND (i.account_scope=json_extract(e.human_key,'$[1]') OR substr(json_extract(e.human_key,'$[1]'),1,8)='session:')\n HAVING count(DISTINCT i.person_id)=1";
4
4
  export declare function openInsideOutStore(path: string): DatabaseSync;
5
- /** Refresh People links without loading transcripts or calling Jev. Known accounts stay exact. Purged grades stay unlinked. */
5
+ /**
6
+ * Refresh People links without loading transcripts or calling Jev. Known accounts stay exact.
7
+ * Purged grades stay unlinked. Matching is a read; only changed links are written, in short batches.
8
+ */
6
9
  export declare function linkInsideOutPeople(path: string): {
7
10
  updated: number;
8
11
  linked: number;
@@ -18,9 +21,30 @@ type BaselineResult = ReturnType<typeof refreshEmotionBaselines>;
18
21
  /** Rebuild local statistics from existing grades, refreshing exact People links first unless the caller just did. */
19
22
  export declare function refreshInsideOutBaselines(path: string, options?: BaselineOptions): BaselineResult;
20
23
  /**
21
- * Keep historical aggregation off the Gateway event loop; serialize refreshes per store.
22
- * Aborting skips queued work and terminates a running worker: SQLite rolls back its open
23
- * batch, committed batches are idempotent, and the next refresh resumes.
24
+ * Worker entry, also behind the synchronous API. `unless`: the fingerprint left by this store's
25
+ * previous refresh; unchanged inputs skip the pass.
26
+ * @public loaded by name from the worker thread
27
+ */
28
+ export declare function refreshWithFingerprint(path: string, options: BaselineOptions & {
29
+ unless?: string;
30
+ }): {
31
+ result: {
32
+ snapshots: number;
33
+ comparisons: number;
34
+ };
35
+ fingerprint?: undefined;
36
+ } | {
37
+ result: {
38
+ snapshots: number;
39
+ comparisons: number;
40
+ };
41
+ fingerprint: string;
42
+ };
43
+ /**
44
+ * Keep historical aggregation off the Gateway event loop; serialize refreshes per store, and skip
45
+ * one whose inputs have not changed since this process's previous refresh of that store.
46
+ * Aborting skips queued work and terminates a running worker. Its connection closes with the
47
+ * worker, so SQLite rolls back the open batch; committed batches are idempotent and the next refresh resumes.
24
48
  */
25
49
  export declare function refreshInsideOutBaselinesAsync(path: string, options?: BaselineOptions, signal?: AbortSignal): Promise<{
26
50
  snapshots: number;
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { Worker } from "node:worker_threads";
3
3
  import { openMemoryDatabase } from "./memory-database.js";
4
- import { PURGED_GRADE, purgeEmotionProfile, refreshEmotionBaselines } from "./emotion-baselines.js";
4
+ import { PURGED_GRADE, baselineFingerprint, purgeEmotionProfile, refreshEmotionBaselines, tableExists } from "./emotion-baselines.js";
5
5
  // Imported by the baseline worker with plain Node resolution: host SDK aliases do not
6
6
  // reach worker threads, so this module and its imports must stay free of `openclaw`.
7
7
  const SCHEMA = `CREATE TABLE IF NOT EXISTS inside_out (
@@ -38,15 +38,31 @@ export function openInsideOutStore(path) {
38
38
  throw error;
39
39
  }
40
40
  }
41
- /** Refresh People links without loading transcripts or calling Jev. Known accounts stay exact. Purged grades stay unlinked. */
41
+ /**
42
+ * Refresh People links without loading transcripts or calling Jev. Known accounts stay exact.
43
+ * Purged grades stay unlinked. Matching is a read; only changed links are written, in short batches.
44
+ */
42
45
  export function linkInsideOutPeople(path) {
43
46
  const db = openInsideOutStore(path);
44
47
  try {
45
48
  let updated = 0;
46
- if (db.prepare("SELECT 1 FROM sqlite_schema WHERE name='person_identities'").get()) {
49
+ if (tableExists(db, "person_identities")) {
47
50
  const person = `(${PERSON_MATCH})`;
48
- updated = Number(db.prepare(`UPDATE inside_out AS e SET person_id=${person}
49
- WHERE person_id IS NOT ${person} AND error IS NOT ?`).run(PURGED_GRADE).changes);
51
+ const changes = db.prepare(`SELECT e.rowid id,${person} person_id FROM inside_out e
52
+ WHERE e.person_id IS NOT ${person} AND e.error IS NOT ?`).all(PURGED_GRADE);
53
+ const link = db.prepare("UPDATE inside_out SET person_id=? WHERE rowid=? AND error IS NOT ?");
54
+ for (let start = 0; start < changes.length; start += 500) {
55
+ db.exec("BEGIN IMMEDIATE");
56
+ try {
57
+ for (const change of changes.slice(start, start + 500))
58
+ updated += Number(link.run(change.person_id, change.id, PURGED_GRADE).changes);
59
+ db.exec("COMMIT");
60
+ }
61
+ catch (error) {
62
+ db.exec("ROLLBACK");
63
+ throw error;
64
+ }
65
+ }
50
66
  }
51
67
  const counts = db.prepare(`SELECT count(person_id) linked,count(*)-count(person_id) unlinked FROM inside_out`).get();
52
68
  return { updated, linked: Number(counts.linked), unlinked: Number(counts.unlinked) };
@@ -57,52 +73,70 @@ export function linkInsideOutPeople(path) {
57
73
  }
58
74
  /** Rebuild local statistics from existing grades, refreshing exact People links first unless the caller just did. */
59
75
  export function refreshInsideOutBaselines(path, options = {}) {
76
+ return refreshWithFingerprint(path, options).result;
77
+ }
78
+ /**
79
+ * Worker entry, also behind the synchronous API. `unless`: the fingerprint left by this store's
80
+ * previous refresh; unchanged inputs skip the pass.
81
+ * @public loaded by name from the worker thread
82
+ */
83
+ export function refreshWithFingerprint(path, options) {
60
84
  if (!existsSync(path))
61
- return { snapshots: 0, comparisons: 0 };
85
+ return { result: { snapshots: 0, comparisons: 0 } };
62
86
  if (options.link !== false)
63
87
  linkInsideOutPeople(path);
64
88
  const db = openInsideOutStore(path);
65
89
  try {
66
- return refreshEmotionBaselines(db, { ...options, minInteractions: options.minInteractions ?? 20 });
90
+ const settings = { ...options, minInteractions: options.minInteractions ?? 20, now: options.now ?? Date.now() };
91
+ if (!options.recompare && options.unless && baselineFingerprint(db, settings) === options.unless) {
92
+ return { result: { snapshots: 0, comparisons: 0 }, fingerprint: options.unless };
93
+ }
94
+ const result = refreshEmotionBaselines(db, settings);
95
+ return { result, fingerprint: baselineFingerprint(db, settings) };
67
96
  }
68
97
  finally {
69
98
  db.close();
70
99
  }
71
100
  }
72
101
  const baselineJobs = new Map();
102
+ const fingerprints = new Map();
73
103
  /**
74
- * Keep historical aggregation off the Gateway event loop; serialize refreshes per store.
75
- * Aborting skips queued work and terminates a running worker: SQLite rolls back its open
76
- * batch, committed batches are idempotent, and the next refresh resumes.
104
+ * Keep historical aggregation off the Gateway event loop; serialize refreshes per store, and skip
105
+ * one whose inputs have not changed since this process's previous refresh of that store.
106
+ * Aborting skips queued work and terminates a running worker. Its connection closes with the
107
+ * worker, so SQLite rolls back the open batch; committed batches are idempotent and the next refresh resumes.
77
108
  */
78
109
  export function refreshInsideOutBaselinesAsync(path, options = {}, signal) {
79
- const settings = { ...options, now: options.now ?? Date.now() };
80
110
  const job = (baselineJobs.get(path) ?? Promise.resolve()).catch(() => { }).then(() => {
81
111
  signal?.throwIfAborted();
82
112
  if (!existsSync(path))
83
113
  return { snapshots: 0, comparisons: 0 };
114
+ const settings = { ...options, now: options.now ?? Date.now(), unless: fingerprints.get(path) };
84
115
  return new Promise((resolve, reject) => {
85
116
  const worker = new Worker(`
86
117
  const { parentPort, workerData } = require("node:worker_threads");
87
118
  // tsx's CLI preload only registers on the main thread. Source-mode
88
119
  // development needs explicit registration; compiled installs do not.
89
120
  if (workerData.module.endsWith(".ts")) require("node:module").createRequire(workerData.module)("tsx/esm/api").register();
90
- import(workerData.module).then(({ refreshInsideOutBaselines }) => {
91
- parentPort.postMessage(refreshInsideOutBaselines(workerData.path, workerData.options));
121
+ import(workerData.module).then(({ refreshWithFingerprint }) => {
122
+ parentPort.postMessage(refreshWithFingerprint(workerData.path, workerData.options));
92
123
  }).catch(error => { throw error; });
93
124
  `, { eval: true, workerData: { module: import.meta.url, path, options: settings } });
94
- let result, aborted = false;
125
+ let outcome, aborted = false;
95
126
  const abort = () => { aborted = true; void worker.terminate(); };
96
127
  signal?.addEventListener("abort", abort, { once: true });
97
- worker.once("message", (value) => { result = value; });
128
+ worker.once("message", (value) => { outcome = value; });
98
129
  worker.once("error", reject);
99
130
  // Settle only after the worker closes its database and exits.
100
131
  worker.once("exit", code => {
101
132
  signal?.removeEventListener("abort", abort);
102
133
  if (aborted)
103
134
  reject(signal.reason);
104
- else if (code === 0 && result)
105
- resolve(result);
135
+ else if (code === 0 && outcome) {
136
+ if (outcome.fingerprint)
137
+ fingerprints.set(path, outcome.fingerprint);
138
+ resolve(outcome.result);
139
+ }
106
140
  else
107
141
  reject(new Error(`Inside Out baseline worker exited without a result (code ${code})`));
108
142
  });
@@ -121,12 +155,15 @@ export function purgeInsideOutPerson(path, personId, options = {}) {
121
155
  // Never create Inside Out tables in a store that has none.
122
156
  const probe = openMemoryDatabase(path);
123
157
  try {
124
- if (!probe.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='inside_out'").get())
158
+ if (!tableExists(probe, "inside_out"))
125
159
  return { baselines: 0, assessments: 0, grades: 0 };
126
160
  }
127
161
  finally {
128
162
  probe.close();
129
163
  }
164
+ // Erasing grades must cover replies linked to this person since the last refresh.
165
+ if (options.grades)
166
+ linkInsideOutPeople(path);
130
167
  const db = openInsideOutStore(path);
131
168
  try {
132
169
  return purgeEmotionProfile(db, personId, options);
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { existsSync } from "node:fs";
3
2
  import { DatabaseSync } from "node:sqlite";
4
3
  import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
@@ -6,7 +5,7 @@ import { messageText } from "./whisperer-context.js";
6
5
  import { conversationUserText } from "./response-text.js";
7
6
  import { estimateJevTokens, requestTypeSafe, resolveTypeSafeApiKey, TYPESAFE_MODEL } from "./typesafe-client.js";
8
7
  import { readInsideOutSources } from "./inside-out-sources.js";
9
- import { INSIDE_OUT_EMOTIONS, PURGED_GRADE, deviationReader, emotionBaselineHistory } from "./emotion-baselines.js";
8
+ import { INSIDE_OUT_EMOTIONS, PURGED_GRADE, deviationReader, emotionBaselineHistory, hash, tableExists } from "./emotion-baselines.js";
10
9
  import { PERSON_MATCH, openInsideOutStore, refreshInsideOutBaselinesAsync } from "./inside-out-store.js";
11
10
  export { linkInsideOutPeople, purgeInsideOutPerson, refreshInsideOutBaselines, refreshInsideOutBaselinesAsync } from "./inside-out-store.js";
12
11
  const RUBRIC = "emotion-presence-v2";
@@ -52,7 +51,6 @@ export function resolveInsideOut(value) {
52
51
  return { enabled, intervalMinutes: integer("intervalMinutes", 0, 10080), maxInteractions: integer("maxInteractions", 1, 10000),
53
52
  maxContextTokens: integer("maxContextTokens", 256, 30000), baselineMinInteractions: integer("baselineMinInteractions", 1, 10000) };
54
53
  }
55
- const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
56
54
  function humanIdentity(source, message) {
57
55
  const meta = message.__openclaw, identity = meta?.senderIdentity;
58
56
  const channel = meta?.transport?.channel ?? identity?.pluginId ?? message.sourceChannel ?? source.channel;
@@ -176,7 +174,7 @@ export async function runInsideOut(options) {
176
174
  const result = { reviewed: 0, cached: 0, failed: 0, sources: 0, skippedSources: 0, errors: [] };
177
175
  try {
178
176
  store = openInsideOutStore(storePath);
179
- const person = store.prepare("SELECT 1 FROM sqlite_schema WHERE name='person_identities'").get()
177
+ const person = tableExists(store, "person_identities")
180
178
  ? store.prepare(`SELECT (${PERSON_MATCH}) person_id FROM (SELECT ? channel,? human_key,? sender_id) e`)
181
179
  : undefined;
182
180
  const existing = store.prepare("SELECT 1 FROM inside_out WHERE interaction_id=? AND rubric=?");
@@ -315,7 +313,7 @@ export function reportInsideOut(path, options = {}) {
315
313
  return [];
316
314
  const db = new DatabaseSync(path, { readOnly: true });
317
315
  try {
318
- if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE name='inside_out'").get())
316
+ if (!tableExists(db, "inside_out"))
319
317
  return [];
320
318
  if (options.personId && !db.prepare("SELECT 1 FROM pragma_table_info('inside_out') WHERE name='person_id'").get())
321
319
  return [];
@@ -494,6 +494,11 @@ export function registerUnblockMemory(api) {
494
494
  api.on("gateway_stop", () => peopleStores.closeAll());
495
495
  }
496
496
  const diagnostics = WhispererDiagnostics.shared();
497
+ // The manifest sets no default for `enabled`, so true here was written by the operator.
498
+ const skillSettings = api.pluginConfig?.skillWhisperer;
499
+ if (skillSettings?.enabled === true && !config.skillWhisperer.enabled) {
500
+ api.logger.warn('unblock-memory skillWhisperer.enabled is true but no corpus has kind "skills"; Skill Whisperer is inactive');
501
+ }
497
502
  registerWhispererPrompt(api, {
498
503
  memory: registerMemoryWhisperer(api, runtime, config.memoryWhisperer, config.typesafe, diagnostics),
499
504
  skill: registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics),
@@ -220,7 +220,7 @@ contract; these tables explain their effects.
220
220
  | `typesafe.apiKey` | Unset | Explicit inline key; mutually exclusive with key file; prefer file |
221
221
  | `typesafe.apiKeyFile` | Unset | Absolute raw-key or dotenv file, reread at credential resolution; explicit missing file never falls back to a different key |
222
222
  | `typesafe.timeoutMs` | `1500` | 1–10,000 per request for Skill/Memory Whisperer, quality/claim/cluster review and response audit; **primer and dossier save use `peoplePrimer.timeoutMs` and Safety Whisperer uses `safetyWhisperer.timeoutMs` instead** |
223
- | `skillWhisperer.enabled` | `true` | Active only with a `skills` corpus (otherwise resolved to `false`, with no hook or host warning) and appropriate host hook access; fixed 3-second total deadline; `false` opts out |
223
+ | `skillWhisperer.enabled` | `true` with a `skills` corpus, else `false` | Needs appropriate host hook access; fixed 3-second total deadline; `false` opts out. An explicit `true` without a skills corpus logs a warning and stays inactive |
224
224
  | `skillWhisperer.historyMessages` | `5` | Nonnegative integer; prior visible messages used for routing |
225
225
  | `skillWhisperer.minScore` | `0.5` | 0–1, **local vector fallback only**, ignored for TypeSafe shortlist admission |
226
226
  | `skillWhisperer.cooldownTurns` | `10` | Nonnegative user-turn count; no fallback to weaker cooling-down alternatives |
@@ -706,12 +706,17 @@ Historical aggregation runs in a worker thread and commits in short batches, so
706
706
  Gateway event loop stays responsive and other writers to `unblock-memory.sqlite`
707
707
  wait for one batch at most, never a whole refresh. Gateway shutdown cancels an
708
708
  active refresh; the next one resumes, and repeating a refresh never duplicates
709
- records. If the refresh after grading fails, `run` still returns its counts with a
709
+ records. The Gateway skips a refresh when nothing it reads (grades, links, people,
710
+ stored results, the current week or settings) has changed since its previous one.
711
+ People links are matched with a read, and only changed links are written, in short
712
+ batches. If the refresh after grading fails, `run` still returns its counts with a
710
713
  `baselineError` (the Gateway logs a warning); saved grades are unaffected.
711
714
  `link-people` also refreshes analytics. `baseline-refresh` does this without loading
712
715
  transcripts, calling Jev, or requiring TypeSafe credentials. `baselines` reads
713
716
  weekly snapshots, newest first, with optional `--person`, `--since YYYY-MM-DD`
714
- (inclusive UTC week-end) and `--limit` (1–1,000 weeks, default 100).
717
+ (weeks whose last day, a Sunday, is on or after that UTC date) and `--limit`
718
+ (1–1,000 weeks, default 100). It reads from one consistent snapshot, even while a
719
+ refresh is writing.
715
720
 
716
721
  Snapshots always reflect the currently linked grades. A late historical grade, a
717
722
  retry, a relink or removed identity evidence rewrites the affected week and every
@@ -725,8 +730,9 @@ grades change snapshots but do not silently rewrite earlier comparisons.
725
730
  Use `baseline-refresh --recompare` to append changed assessments after backfill,
726
731
  identity or sample-policy changes; `export --deviation-history` includes the earlier
727
732
  results and marks which match the current grade. Repeating an unchanged refresh
728
- adds no duplicate records. Previously sparse or future-dated assessments also need
729
- explicit re-comparison when their circumstances change.
733
+ adds no duplicate records. A `future_time` assessment is redone automatically once
734
+ the reply's time has passed. Previously sparse assessments need explicit
735
+ re-comparison after backfill adds earlier history.
730
736
 
731
737
  `computed_at` and `assessedAt` record when these calculations actually occurred.
732
738
  A backfilled comparison uses retained evidence from earlier interaction weeks, but
@@ -751,8 +757,12 @@ removed. It makes no Jev calls.
751
757
  - **With `--grades`**, it also erases the six probabilities of every reply currently
752
758
  linked to that person and unlinks it. Each row stays as a tombstone with the error
753
759
  `Purged by operator`, so `run` (including `--retry`) never sends that reply to Jev
754
- again, `link-people` never relinks it, and refreshes skip it. This cannot be
755
- undone; run `link-people` first so every reply from that person is linked.
760
+ again, `link-people` never relinks it, and refreshes skip it. Purge refreshes
761
+ People links first, so replies linked since the last refresh are included. This
762
+ cannot be undone.
763
+
764
+ A refresh already running when `purge` commits rechecks every grade before writing,
765
+ so it cannot restore the purged data.
756
766
 
757
767
  Purging removes existing data only. While Inside Out stays enabled and the person's
758
768
  identity remains in PeopleSQL, their new replies are graded and start a new
package/docs/retrieval.md CHANGED
@@ -472,7 +472,8 @@ Skill Whisperer is a semantic reminder for user turns. It turns on by default on
472
472
  you configure one isolated `skills` corpus and authorize
473
473
  `plugins.entries.unblock-memory.hooks.allowConversationAccess`. Without a skills
474
474
  corpus it stays inactive: no prompt hook is registered and diagnostics report it
475
- disabled. Set `skillWhisperer.enabled` to `false` to opt out. It
475
+ disabled; an explicit `skillWhisperer.enabled: true` without one logs a warning.
476
+ Set `skillWhisperer.enabled` to `false` to opt out. It
476
477
  embeds the current prompt plus the configured number of prior user/assistant
477
478
  messages, compares it with each configured skill's frontmatter `name` and
478
479
  `description`. With TypeSafe enabled and a key available, the top three valid
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.10.0",
4
+ "version": "0.10.1",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": true },
@@ -349,13 +349,12 @@
349
349
  "type": "object",
350
350
  "additionalProperties": false,
351
351
  "properties": {
352
- "enabled": { "type": "boolean", "default": true, "description": "Takes effect only when a corpus with kind \"skills\" is configured." },
352
+ "enabled": { "type": "boolean", "description": "Defaults to true when a corpus with kind \"skills\" is configured, otherwise false. An explicit true without one is ignored with a warning." },
353
353
  "historyMessages": { "type": "integer", "minimum": 0, "default": 5 },
354
354
  "minScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 },
355
355
  "cooldownTurns": { "type": "integer", "minimum": 0, "default": 10 }
356
356
  },
357
357
  "default": {
358
- "enabled": true,
359
358
  "historyMessages": 5,
360
359
  "minScore": 0.5,
361
360
  "cooldownTurns": 10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",