@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.
- package/dist/src/emotion-baselines.d.ts +14 -1
- package/dist/src/emotion-baselines.js +51 -10
- package/dist/src/inside-out-store.d.ts +28 -4
- package/dist/src/inside-out-store.js +55 -18
- package/dist/src/inside-out.js +3 -5
- package/dist/src/plugin.js +5 -0
- package/docs/configuration.md +17 -7
- package/docs/retrieval.md +2 -1
- package/openclaw.plugin.json +2 -3
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
49
|
+
if (tableExists(db, "person_identities")) {
|
|
47
50
|
const person = `(${PERSON_MATCH})`;
|
|
48
|
-
|
|
49
|
-
WHERE person_id IS NOT ${person} AND error IS NOT ?`).
|
|
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
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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(({
|
|
91
|
-
parentPort.postMessage(
|
|
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
|
|
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) => {
|
|
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 &&
|
|
105
|
-
|
|
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
|
|
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);
|
package/dist/src/inside-out.js
CHANGED
|
@@ -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
|
|
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
|
|
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 [];
|
package/dist/src/plugin.js
CHANGED
|
@@ -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),
|
package/docs/configuration.md
CHANGED
|
@@ -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`
|
|
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.
|
|
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
|
-
(
|
|
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.
|
|
729
|
-
|
|
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.
|
|
755
|
-
|
|
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
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.10.
|
|
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", "
|
|
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
|