@unblocklabs/unblock-memory 0.9.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/README.md CHANGED
@@ -110,7 +110,7 @@ Everything below is off until you configure it.
110
110
  | Feature | What it does |
111
111
  | --- | --- |
112
112
  | [Session indexing](docs/retrieval.md#sessions) | Makes past conversations searchable. Group and channel chats by default; DMs only if you add them. |
113
- | [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill for a user message. Never runs it. Works without TypeSafe. |
113
+ | [Skill Whisperer](docs/retrieval.md#skill-whisperer) | Suggests one relevant skill for a user message. Never runs it. Turns on when you add a `skills` corpus; works without TypeSafe. |
114
114
  | [Memory Whisperer](docs/retrieval.md#memory-whisperer) | Adds up to two relevant past excerpts before the agent replies. Needs TypeSafe and a self-hosted query-generation API. |
115
115
  | [Safety Whisperer](docs/configuration.md#safety-whisperer) | Reminds the agent not to share sensitive topics, such as one person's salary, with requestors you haven't approved. A prompt-level guardrail, not access control; fails open if TypeSafe is unavailable. |
116
116
  | [People dossiers](docs/peoplesql.md) | Lets the agent keep a short, evidence-backed note about each person it works with. |
@@ -120,7 +120,7 @@ Everything below is off until you configure it.
120
120
  | [Clustering and curation](docs/retrieval.md#memory-analysis) | Groups related memories so the agent can spot duplicates and maintain its knowledge. Needs a local analysis worker. |
121
121
  | [Quality and claim review](docs/retrieval.md#review-and-diagnostics) | Flags noisy indexed content and checks whether a claim is supported by its sources. Advisory only. |
122
122
  | [Response audit](docs/response-audit.md) | Operator-only grading of reply quality and sentiment in approved Slack conversations. |
123
- | [Inside Out](docs/configuration.md#inside-out) | Operator-only scores for six emotions in human replies, with reports. Never changes memory or prompts. |
123
+ | [Inside Out](docs/configuration.md#inside-out) | Operator-only scores for six emotions in human replies, compared with each person's own weekly baseline, with reports. Never changes memory or prompts. |
124
124
 
125
125
  Whisperers also need conversation-hook and prompt-injection permissions on the
126
126
  plugin entry; see [host controls](docs/configuration.md#host-controls). Each
@@ -78,7 +78,7 @@ function resolveTypeSafe(value) {
78
78
  };
79
79
  }
80
80
  const DEFAULT_SKILL_WHISPERER = {
81
- enabled: false,
81
+ enabled: true,
82
82
  historyMessages: 5,
83
83
  minScore: 0.5,
84
84
  cooldownTurns: 10,
@@ -345,7 +345,7 @@ export function resolveConfig(value) {
345
345
  }
346
346
  const value = config.skillWhisperer;
347
347
  assertOnlyKeys(value, ["enabled", "historyMessages", "minScore", "cooldownTurns"], "skillWhisperer");
348
- const enabled = value.enabled ?? false;
348
+ const enabled = value.enabled ?? DEFAULT_SKILL_WHISPERER.enabled;
349
349
  const historyMessages = value.historyMessages ?? 5;
350
350
  const minScore = value.minScore ?? 0.5;
351
351
  const cooldownTurns = value.cooldownTurns ?? 10;
@@ -369,8 +369,11 @@ export function resolveConfig(value) {
369
369
  }
370
370
  skillWhisperer = { enabled, historyMessages, minScore, cooldownTurns };
371
371
  }
372
+ // Default-on means "on once skills exist": without a skills corpus it could never
373
+ // hint, so it registers no prompt hook and reports inactive. Host-applied schema
374
+ // defaults are indistinguishable from an explicit true, so this never throws.
372
375
  if (skillWhisperer.enabled && !corpora.some((corpus) => corpus.kind === "skills")) {
373
- throw new Error('unblock-memory enabled skillWhisperer requires a corpus named "skills" with kind "skills"');
376
+ skillWhisperer = { ...skillWhisperer, enabled: false };
374
377
  }
375
378
  return { corpora, keepEmbeddingModelWarm, analysis: analysisConfig, people, peoplePrimer, skillWhisperer,
376
379
  extraction: resolveExtraction(config.extraction, corpora),
@@ -95,8 +95,8 @@ export declare class WhispererDiagnostics {
95
95
  [k: string]: {
96
96
  calls: number;
97
97
  outcomes: {
98
- ok?: number | undefined;
99
98
  cancelled?: number | undefined;
99
+ ok?: number | undefined;
100
100
  partial?: number | undefined;
101
101
  skipped?: number | undefined;
102
102
  failed?: number | undefined;
@@ -104,7 +104,7 @@ export declare class WhispererDiagnostics {
104
104
  timed_out?: number | undefined;
105
105
  };
106
106
  slowestRecent: {
107
- outcome: "ok" | "cancelled" | "partial" | "skipped" | "failed" | "empty" | "timed_out";
107
+ outcome: "cancelled" | "ok" | "partial" | "skipped" | "failed" | "empty" | "timed_out";
108
108
  setupMs?: number | undefined;
109
109
  queueWaitMs?: number | undefined;
110
110
  metadataMs?: number | undefined;
@@ -0,0 +1,133 @@
1
+ import type { DatabaseSync, SQLOutputValue } from "node:sqlite";
2
+ export declare const INSIDE_OUT_EMOTIONS: readonly ["joy", "sadness", "fear", "anger", "disgust", "surprise"];
3
+ /** Erased by an operator purge: never retried, linked, aggregated or compared. */
4
+ export declare const PURGED_GRADE = "Purged by operator";
5
+ type Emotion = typeof INSIDE_OUT_EMOTIONS[number];
6
+ type Row = Record<string, SQLOutputValue>;
7
+ export declare const hash: (value: unknown) => string;
8
+ export declare const tableExists: (db: DatabaseSync, name: string) => boolean;
9
+ type Statistics = {
10
+ count: number;
11
+ mean: number | null;
12
+ min: number | null;
13
+ p10: number | null;
14
+ median: number | null;
15
+ p90: number | null;
16
+ max: number | null;
17
+ distribution: [number, number][];
18
+ };
19
+ type Summary = Record<Emotion, Statistics>;
20
+ type Deviation = {
21
+ delta: number;
22
+ percentile: number;
23
+ unusualness: number;
24
+ };
25
+ type Comparison = {
26
+ status: "ok" | "insufficient_history";
27
+ count: number;
28
+ emotions: Record<Emotion, Deviation> | null;
29
+ };
30
+ type PersonalDeviation = {
31
+ version: string;
32
+ status: "ok" | "insufficient_history" | "unlinked_person" | "missing_time" | "future_time" | "grade_failed";
33
+ personId: string | null;
34
+ baselineId: string | null;
35
+ baselineEnd: number | null;
36
+ assessedAt: number;
37
+ minInteractions: number;
38
+ cumulative: Comparison | null;
39
+ weekly: Comparison | null;
40
+ };
41
+ /**
42
+ * One current, content-addressed snapshot per person, rubric, model and completed week.
43
+ * Reads finish before each batch commits, so the shared store is never held for a whole pass.
44
+ * No inference and no changes to raw grades.
45
+ */
46
+ export declare function refreshEmotionBaselines(db: DatabaseSync, options: {
47
+ minInteractions: number;
48
+ now?: number;
49
+ recompare?: boolean;
50
+ }): {
51
+ snapshots: number;
52
+ comparisons: number;
53
+ };
54
+ /** Prepared once per export, not per row. */
55
+ export declare function deviationReader(db: DatabaseSync): (row: Row, history?: boolean) => PersonalDeviation | {
56
+ status: string;
57
+ } | {
58
+ history: {
59
+ currentGrade: boolean;
60
+ version: string;
61
+ status: "ok" | "insufficient_history" | "unlinked_person" | "missing_time" | "future_time" | "grade_failed";
62
+ personId: string | null;
63
+ baselineId: string | null;
64
+ baselineEnd: number | null;
65
+ assessedAt: number;
66
+ minInteractions: number;
67
+ cumulative: Comparison | null;
68
+ weekly: Comparison | null;
69
+ }[];
70
+ version: string;
71
+ status: "ok" | "insufficient_history" | "unlinked_person" | "missing_time" | "future_time" | "grade_failed";
72
+ personId: string | null;
73
+ baselineId: string | null;
74
+ baselineEnd: number | null;
75
+ assessedAt: number;
76
+ minInteractions: number;
77
+ cumulative: Comparison | null;
78
+ weekly: Comparison | null;
79
+ } | {
80
+ history: {
81
+ currentGrade: boolean;
82
+ version: string;
83
+ status: "ok" | "insufficient_history" | "unlinked_person" | "missing_time" | "future_time" | "grade_failed";
84
+ personId: string | null;
85
+ baselineId: string | null;
86
+ baselineEnd: number | null;
87
+ assessedAt: number;
88
+ minInteractions: number;
89
+ cumulative: Comparison | null;
90
+ weekly: Comparison | null;
91
+ }[];
92
+ status: "not_compared";
93
+ };
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
+ */
98
+ export declare function emotionBaselineHistory(db: DatabaseSync, options?: {
99
+ personId?: string;
100
+ since?: number;
101
+ limit?: number;
102
+ }): {
103
+ id: string;
104
+ person_id: string;
105
+ rubric: string;
106
+ model: string;
107
+ version: string;
108
+ week_start: number;
109
+ week_end: number;
110
+ computed_at: number;
111
+ weekly: Summary;
112
+ cumulative: Summary;
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;
122
+ /**
123
+ * Delete one person's baselines and assessment history. With `grades`, also erase their six
124
+ * probabilities and person link, keeping a tombstone so the reply is never sent to Jev again.
125
+ */
126
+ export declare function purgeEmotionProfile(db: DatabaseSync, personId: string, options?: {
127
+ grades?: boolean;
128
+ }): {
129
+ baselines: number;
130
+ assessments: number;
131
+ grades: number;
132
+ };
133
+ export {};
@@ -0,0 +1,428 @@
1
+ import { createHash } from "node:crypto";
2
+ export const INSIDE_OUT_EMOTIONS = ["joy", "sadness", "fear", "anger", "disgust", "surprise"];
3
+ /** Erased by an operator purge: never retried, linked, aggregated or compared. */
4
+ export const PURGED_GRADE = "Purged by operator";
5
+ const WEEK = 7 * 86400_000;
6
+ const MONDAY = Date.UTC(1970, 0, 5);
7
+ const VERSION = "personal-deviation-v2";
8
+ // 0.01-wide bins bound each stored distribution to 101 values per emotion, however long the history.
9
+ const STEPS = 100;
10
+ // Writers on the shared store wait for at most one short batch, never a whole refresh.
11
+ const BATCH = 500;
12
+ const PAGE = 2000;
13
+ export const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
14
+ const weekStart = (time) => MONDAY + Math.floor((time - MONDAY) / WEEK) * WEEK;
15
+ const bin = (value) => Math.round(value * STEPS);
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(",")}`;
18
+ const VALID = INSIDE_OUT_EMOTIONS.map(e => `e.${e} BETWEEN 0 AND 1`).join(" AND ");
19
+ // Comparable grades: successful, timed, not after `now`. Callers add the People join.
20
+ const COMPARABLE = `e.reviewed_at<=? AND e.error IS NULL AND e.target_at IS NOT NULL AND e.target_at<=? AND ${VALID}`;
21
+ function schemaVersion(db) {
22
+ if (!tableExists(db, "memory_schema"))
23
+ return undefined;
24
+ const version = db.prepare("SELECT version FROM memory_schema WHERE component='emotion-baselines'").get()?.version;
25
+ return version === undefined ? undefined : Number(version);
26
+ }
27
+ /** Separate analytics tables: never dossier fields or prompt-injection state. */
28
+ function initializeEmotionBaselines(db) {
29
+ if (schemaVersion(db) === 2)
30
+ return;
31
+ db.exec("BEGIN IMMEDIATE");
32
+ try {
33
+ const version = schemaVersion(db);
34
+ if (version !== undefined && version !== 1 && version !== 2)
35
+ throw new Error("Unsupported Inside Out baseline storage version");
36
+ // Unreleased v1 kept every raw-value revision. Both tables are derived and rebuild from grades.
37
+ if (version === 1)
38
+ db.exec("DROP TABLE IF EXISTS inside_out_deviations; DROP TABLE IF EXISTS person_emotion_baselines;");
39
+ db.exec(`CREATE TABLE IF NOT EXISTS person_emotion_baselines (
40
+ person_id TEXT NOT NULL, rubric TEXT NOT NULL, model TEXT NOT NULL, week_start INTEGER NOT NULL,
41
+ id TEXT NOT NULL, version TEXT NOT NULL, week_end INTEGER NOT NULL, computed_at INTEGER NOT NULL, weekly_json TEXT NOT NULL,
42
+ PRIMARY KEY(person_id,rubric,model,week_start)
43
+ ) STRICT;
44
+ CREATE TABLE IF NOT EXISTS inside_out_deviations (
45
+ id TEXT PRIMARY KEY, interaction_id TEXT NOT NULL, rubric TEXT NOT NULL, grade_signature TEXT NOT NULL,
46
+ assessed_at INTEGER NOT NULL, baseline_id TEXT, person_id TEXT, result_json TEXT NOT NULL
47
+ ) STRICT;
48
+ CREATE INDEX IF NOT EXISTS inside_out_deviations_interaction ON inside_out_deviations(interaction_id,rubric);
49
+ CREATE INDEX IF NOT EXISTS inside_out_deviations_person ON inside_out_deviations(person_id);
50
+ INSERT OR REPLACE INTO memory_schema VALUES ('emotion-baselines',2);`);
51
+ db.exec("COMMIT");
52
+ }
53
+ catch (error) {
54
+ db.exec("ROLLBACK");
55
+ throw error;
56
+ }
57
+ }
58
+ function gradeSignature(row) {
59
+ return hash([row.interaction_id, row.rubric, row.model, row.person_id ?? null, row.target_at,
60
+ row.reviewed_at, row.error, ...INSIDE_OUT_EMOTIONS.map(e => row[e])]);
61
+ }
62
+ function histograms() {
63
+ const empty = () => ({ count: 0, sum: 0, carry: 0, min: Infinity, max: -Infinity, bins: new Array(STEPS + 1).fill(0) });
64
+ return { joy: empty(), sadness: empty(), fear: empty(), anger: empty(), disgust: empty(), surprise: empty() };
65
+ }
66
+ // Neumaier summation keeps means exact for repeated values: twenty 0.8s average to 0.8.
67
+ function accumulate(target, value, count, min, max) {
68
+ const total = target.sum + value;
69
+ target.carry += Math.abs(target.sum) >= Math.abs(value) ? target.sum - total + value : value - total + target.sum;
70
+ target.sum = total;
71
+ target.count += count;
72
+ target.min = Math.min(target.min, min);
73
+ target.max = Math.max(target.max, max);
74
+ }
75
+ function add(target, row) {
76
+ for (const emotion of INSIDE_OUT_EMOTIONS) {
77
+ const value = Number(row[emotion]);
78
+ accumulate(target[emotion], value, 1, value, value);
79
+ target[emotion].bins[bin(value)]++;
80
+ }
81
+ }
82
+ function merge(target, source) {
83
+ for (const emotion of INSIDE_OUT_EMOTIONS) {
84
+ const from = source[emotion], to = target[emotion];
85
+ accumulate(to, from.sum + from.carry, from.count, from.min, from.max);
86
+ from.bins.forEach((n, i) => { to.bins[i] += n; });
87
+ }
88
+ }
89
+ const distribution = (histogram) => histogram.bins.flatMap((n, i) => n ? [[i / STEPS, n]] : []);
90
+ /** Stored form: exact count, sum, min and max with sparse bins, so quiet weeks stay tiny. */
91
+ function encode(week) {
92
+ return JSON.stringify(Object.fromEntries(INSIDE_OUT_EMOTIONS.map(emotion => {
93
+ const h = week[emotion];
94
+ return [emotion, { count: h.count, sum: h.sum + h.carry, min: h.count ? h.min : null, max: h.count ? h.max : null, bins: distribution(h) }];
95
+ })));
96
+ }
97
+ function decode(json) {
98
+ const stored = JSON.parse(json);
99
+ const week = histograms();
100
+ for (const emotion of INSIDE_OUT_EMOTIONS) {
101
+ const s = stored[emotion], h = week[emotion];
102
+ Object.assign(h, { count: s.count, sum: s.sum, min: s.min ?? Infinity, max: s.max ?? -Infinity });
103
+ for (const [value, n] of s.bins)
104
+ h.bins[bin(value)] = n;
105
+ }
106
+ return week;
107
+ }
108
+ function summarize(week) {
109
+ const statistics = (h) => {
110
+ const values = distribution(h);
111
+ const at = (rank) => {
112
+ let seen = 0;
113
+ for (const [value, frequency] of values) {
114
+ seen += frequency;
115
+ if (rank < seen)
116
+ return value;
117
+ }
118
+ return values.at(-1)[0];
119
+ };
120
+ // Bin centres can sit just outside the exact extremes; quantiles never do.
121
+ const quantile = (p) => {
122
+ if (!h.count)
123
+ return null;
124
+ const position = (h.count - 1) * p, lower = Math.floor(position);
125
+ return Math.min(h.max, Math.max(h.min, at(lower) + (at(Math.ceil(position)) - at(lower)) * (position - lower)));
126
+ };
127
+ return { count: h.count, mean: h.count ? (h.sum + h.carry) / h.count : null, min: h.count ? h.min : null,
128
+ p10: quantile(0.1), median: quantile(0.5), p90: quantile(0.9), max: h.count ? h.max : null, distribution: values };
129
+ };
130
+ return { joy: statistics(week.joy), sadness: statistics(week.sadness), fear: statistics(week.fear),
131
+ anger: statistics(week.anger), disgust: statistics(week.disgust), surprise: statistics(week.surprise) };
132
+ }
133
+ /** Freeze a population's rank prefixes before later grades join it. */
134
+ function ranks(population) {
135
+ const freeze = (h) => {
136
+ const prefix = [0];
137
+ for (const n of h.bins)
138
+ prefix.push(prefix.at(-1) + n);
139
+ return { count: h.count, mean: (h.sum + h.carry) / h.count, prefix };
140
+ };
141
+ return { joy: freeze(population.joy), sadness: freeze(population.sadness), fear: freeze(population.fear),
142
+ anger: freeze(population.anger), disgust: freeze(population.disgust), surprise: freeze(population.surprise) };
143
+ }
144
+ function compare(row, population, minimum) {
145
+ const count = population.joy.count;
146
+ if (count < minimum)
147
+ return { status: "insufficient_history", count, emotions: null };
148
+ const deviation = (emotion) => {
149
+ const score = Number(row[emotion]), { mean, prefix } = population[emotion], b = bin(score);
150
+ // Midrank keeps a tied, constant baseline at 0.5 rather than treating every tie as extreme.
151
+ const percentile = (prefix[b] + (prefix[b + 1] - prefix[b]) / 2) / count;
152
+ return { delta: score - mean, percentile, unusualness: Math.abs(2 * percentile - 1) };
153
+ };
154
+ return { status: "ok", count, emotions: { joy: deviation("joy"), sadness: deviation("sadness"), fear: deviation("fear"),
155
+ anger: deviation("anger"), disgust: deviation("disgust"), surprise: deviation("surprise") } };
156
+ }
157
+ /** Queue writes and commit them in short IMMEDIATE transactions. Only call between completed reads. */
158
+ function batches(db) {
159
+ const pending = [];
160
+ const flush = () => {
161
+ if (!pending.length)
162
+ return;
163
+ db.exec("BEGIN IMMEDIATE");
164
+ try {
165
+ for (const write of pending)
166
+ write();
167
+ db.exec("COMMIT");
168
+ }
169
+ catch (error) {
170
+ db.exec("ROLLBACK");
171
+ throw error;
172
+ }
173
+ pending.length = 0;
174
+ };
175
+ return { write(change) { pending.push(change); if (pending.length >= BATCH)
176
+ flush(); }, flush };
177
+ }
178
+ /**
179
+ * One current, content-addressed snapshot per person, rubric, model and completed week.
180
+ * Reads finish before each batch commits, so the shared store is never held for a whole pass.
181
+ * No inference and no changes to raw grades.
182
+ */
183
+ export function refreshEmotionBaselines(db, options) {
184
+ const now = options.now ?? Date.now(), cutoff = weekStart(now), minimum = options.minInteractions;
185
+ if (!Number.isFinite(now) || !Number.isInteger(minimum) || minimum < 1)
186
+ throw new Error("Invalid baseline settings");
187
+ initializeEmotionBaselines(db);
188
+ let snapshots = 0, comparisons = 0;
189
+ const queue = batches(db), people = tableExists(db, "people");
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`);
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=?`);
194
+ const assess = (row, signature, decide) => {
195
+ const previous = prior.get(row.interaction_id, row.rubric, signature);
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")
198
+ return;
199
+ const result = { version: VERSION, status: "insufficient_history", personId: row.person_id === null ? null : String(row.person_id),
200
+ baselineId: null, baselineEnd: null, assessedAt: now, minInteractions: minimum, cumulative: null, weekly: null };
201
+ decide(result);
202
+ const key = (assessment) => hash([assessment.version, signature, assessment.baselineId, assessment.status, assessment.minInteractions]);
203
+ if (previous && key(JSON.parse(String(previous.result_json))) === key(result))
204
+ return;
205
+ // Chaining records permits an explicit return to an earlier baseline without
206
+ // selecting an obsolete intervening assessment or losing either observation.
207
+ const id = hash([key(result), previous?.id ?? null]);
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;
213
+ comparisons += Number(save.run(id, row.interaction_id, row.rubric, signature, now, result.baselineId, result.personId, JSON.stringify(result)).changes);
214
+ });
215
+ };
216
+ const populations = new Set();
217
+ if (people) {
218
+ const stored = db.prepare("SELECT week_start,id FROM person_emotion_baselines WHERE person_id=? AND rubric=? AND model=?");
219
+ const upsert = db.prepare(`INSERT INTO person_emotion_baselines VALUES (?,?,?,?,?,?,?,?,?)
220
+ ON CONFLICT(person_id,rubric,model,week_start) DO UPDATE SET id=excluded.id,version=excluded.version,
221
+ week_end=excluded.week_end,computed_at=excluded.computed_at,weekly_json=excluded.weekly_json`);
222
+ const remove = db.prepare("DELETE FROM person_emotion_baselines WHERE person_id=? AND rubric=? AND model=? AND week_start=? AND id=?");
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}
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 ?`);
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
227
+ WHERE ${COMPARABLE}`).all(now, now);
228
+ for (const group of groups) {
229
+ const { person_id: person, rubric, model } = group;
230
+ populations.add(JSON.stringify([person, rubric, model]));
231
+ const existing = new Map(stored.all(person, rubric, model).map(row => [Number(row.week_start), String(row.id)]));
232
+ function* rows() {
233
+ let last;
234
+ for (;;) {
235
+ const batch = page.all(person, rubric, model, now, now, last?.target_at ?? null, last?.target_at ?? null, last?.target_at ?? null, last?.interaction_id ?? null, PAGE);
236
+ yield* batch;
237
+ if (batch.length < PAGE)
238
+ return;
239
+ last = batch.at(-1);
240
+ }
241
+ }
242
+ const source = rows();
243
+ let next = source.next(), previous;
244
+ const cumulative = histograms(), current = new Set();
245
+ for (let start = next.done ? cutoff : weekStart(Number(next.value.target_at)); !next.done || start < cutoff; start += WEEK) {
246
+ const end = start + WEEK, weekly = histograms(), signatures = [];
247
+ // Grades compare only with completed weeks before their own; this week joins afterwards.
248
+ let frozen;
249
+ while (!next.done && Number(next.value.target_at) < end) {
250
+ const row = next.value, signature = gradeSignature(row);
251
+ assess(row, signature, result => {
252
+ if (!previous)
253
+ return;
254
+ frozen ??= { cumulative: ranks(cumulative), weekly: ranks(previous.weekly) };
255
+ result.baselineId = previous.id;
256
+ result.baselineEnd = previous.end;
257
+ result.cumulative = compare(row, frozen.cumulative, minimum);
258
+ result.weekly = compare(row, frozen.weekly, minimum);
259
+ result.status = result.cumulative.status;
260
+ });
261
+ if (start < cutoff) {
262
+ signatures.push(signature);
263
+ add(weekly, row);
264
+ }
265
+ next = source.next();
266
+ }
267
+ if (start >= cutoff)
268
+ continue;
269
+ merge(cumulative, weekly);
270
+ // Chained ids address the whole cumulative population without rehashing earlier weeks.
271
+ // Empty weeks are kept: a weekly comparison always means the preceding calendar week.
272
+ const id = hash([VERSION, person, rubric, model, start, previous?.id ?? null, signatures]), json = encode(weekly);
273
+ current.add(start);
274
+ if (existing.get(start) !== id)
275
+ queue.write(() => {
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);
279
+ });
280
+ previous = { id, end, weekly };
281
+ }
282
+ // Weeks no longer backed by linked grades are replaced, not retained as revisions.
283
+ for (const [start, id] of existing)
284
+ if (!current.has(start))
285
+ queue.write(() => { remove.run(person, rubric, model, start, id); });
286
+ }
287
+ }
288
+ // Populations without linked grades (removed identity or person) lose their snapshots.
289
+ const drop = db.prepare("DELETE FROM person_emotion_baselines WHERE person_id=? AND rubric=? AND model=?");
290
+ for (const row of db.prepare("SELECT DISTINCT person_id,rubric,model FROM person_emotion_baselines").all()) {
291
+ if (!populations.has(JSON.stringify([row.person_id, row.rubric, row.model])))
292
+ queue.write(() => { drop.run(row.person_id, row.rubric, row.model); });
293
+ }
294
+ // Everything not comparable records an explicit status, never a neutral score.
295
+ const comparable = people ? `coalesce(${COMPARABLE} AND EXISTS (SELECT 1 FROM people p WHERE p.id=e.person_id),0)` : "0";
296
+ const linked = people ? db.prepare("SELECT 1 FROM people WHERE id=?") : undefined;
297
+ const others = db.prepare(`SELECT rowid AS _rowid,* FROM inside_out e WHERE e.reviewed_at<=? AND e.rowid>?
298
+ AND (e.error IS NULL OR e.error<>?) AND NOT ${comparable} ORDER BY e.rowid LIMIT ?`);
299
+ for (let last = -Infinity;;) {
300
+ const batch = others.all(now, last, PURGED_GRADE, ...(people ? [now, now] : []), PAGE);
301
+ for (const row of batch) {
302
+ assess(row, gradeSignature(row), result => {
303
+ if (row.error !== null || !INSIDE_OUT_EMOTIONS.every(e => typeof row[e] === "number" && Number.isFinite(row[e]) && row[e] >= 0 && row[e] <= 1))
304
+ result.status = "grade_failed";
305
+ else if (!row.person_id || !linked?.get(row.person_id))
306
+ result.status = "unlinked_person";
307
+ else if (row.target_at === null)
308
+ result.status = "missing_time";
309
+ else
310
+ result.status = "future_time";
311
+ });
312
+ }
313
+ if (batch.length < PAGE)
314
+ break;
315
+ last = Number(batch.at(-1)._rowid);
316
+ queue.flush();
317
+ }
318
+ queue.flush();
319
+ return { snapshots, comparisons };
320
+ }
321
+ /** Prepared once per export, not per row. */
322
+ export function deviationReader(db) {
323
+ const entries = schemaVersion(db) === 2 ? db.prepare(`SELECT grade_signature,result_json FROM inside_out_deviations
324
+ WHERE interaction_id=? AND rubric=? ORDER BY assessed_at DESC,rowid DESC`) : undefined;
325
+ return (row, history = false) => {
326
+ if (!entries)
327
+ return { status: "not_compared" };
328
+ const all = entries.all(row.interaction_id, row.rubric);
329
+ const signature = gradeSignature(row), latest = all.find(entry => entry.grade_signature === signature);
330
+ const current = latest ? JSON.parse(String(latest.result_json)) : { status: "not_compared" };
331
+ return history ? { ...current, history: all.map(entry => {
332
+ const result = JSON.parse(String(entry.result_json));
333
+ return { ...result, currentGrade: entry.grade_signature === signature };
334
+ }) } : current;
335
+ };
336
+ }
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
+ */
341
+ export function emotionBaselineHistory(db, options = {}) {
342
+ const limit = options.limit ?? 100;
343
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
344
+ throw new Error("Baseline limit must be between 1 and 1000");
345
+ if (schemaVersion(db) !== 2)
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) {
357
+ const selected = db.prepare(`SELECT person_id,rubric,model,week_start FROM person_emotion_baselines
358
+ WHERE (? IS NULL OR person_id=?) AND (? IS NULL OR week_end>?) ORDER BY week_end DESC,person_id,rubric,model LIMIT ?`)
359
+ .all(options.personId ?? null, options.personId ?? null, options.since ?? null, options.since ?? null, limit);
360
+ const wanted = new Map();
361
+ for (const row of selected) {
362
+ const population = JSON.stringify([row.person_id, row.rubric, row.model]);
363
+ wanted.set(population, (wanted.get(population) ?? new Set()).add(Number(row.week_start)));
364
+ }
365
+ const weeks = db.prepare("SELECT * FROM person_emotion_baselines WHERE person_id=? AND rubric=? AND model=? ORDER BY week_start");
366
+ const snapshots = new Map();
367
+ const snapshot = (row, weekly, cumulative) => ({ id: String(row.id), person_id: String(row.person_id),
368
+ rubric: String(row.rubric), model: String(row.model), version: String(row.version), week_start: Number(row.week_start),
369
+ week_end: Number(row.week_end), computed_at: Number(row.computed_at), weekly: summarize(weekly), cumulative: summarize(cumulative) });
370
+ for (const [population, starts] of wanted) {
371
+ const cumulative = histograms(), latest = Math.max(...starts);
372
+ for (const row of weeks.all(...JSON.parse(population))) {
373
+ const weekly = decode(String(row.weekly_json));
374
+ merge(cumulative, weekly);
375
+ if (starts.has(Number(row.week_start)))
376
+ snapshots.set(JSON.stringify([population, row.week_start]), snapshot(row, weekly, cumulative));
377
+ if (Number(row.week_start) >= latest)
378
+ break;
379
+ }
380
+ }
381
+ return selected.map(row => snapshots.get(JSON.stringify([JSON.stringify([row.person_id, row.rubric, row.model]), row.week_start])));
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
+ }
402
+ /**
403
+ * Delete one person's baselines and assessment history. With `grades`, also erase their six
404
+ * probabilities and person link, keeping a tombstone so the reply is never sent to Jev again.
405
+ */
406
+ export function purgeEmotionProfile(db, personId, options = {}) {
407
+ const counts = { baselines: 0, assessments: 0, grades: 0 };
408
+ if (!tableExists(db, "inside_out"))
409
+ return counts;
410
+ initializeEmotionBaselines(db);
411
+ db.exec("BEGIN IMMEDIATE");
412
+ try {
413
+ if (options.grades) {
414
+ counts.assessments += Number(db.prepare(`DELETE FROM inside_out_deviations WHERE (interaction_id,rubric) IN
415
+ (SELECT interaction_id,rubric FROM inside_out WHERE person_id=?)`).run(personId).changes);
416
+ counts.grades = Number(db.prepare(`UPDATE inside_out SET ${INSIDE_OUT_EMOTIONS.map(e => `${e}=NULL`).join(",")},
417
+ error=?,person_id=NULL WHERE person_id=?`).run(PURGED_GRADE, personId).changes);
418
+ }
419
+ counts.assessments += Number(db.prepare("DELETE FROM inside_out_deviations WHERE person_id=?").run(personId).changes);
420
+ counts.baselines = Number(db.prepare("DELETE FROM person_emotion_baselines WHERE person_id=?").run(personId).changes);
421
+ db.exec("COMMIT");
422
+ return counts;
423
+ }
424
+ catch (error) {
425
+ db.exec("ROLLBACK");
426
+ throw error;
427
+ }
428
+ }