soulbridge 0.0.3 → 0.0.4

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.
@@ -0,0 +1,62 @@
1
+ /** Multiplicative-ish adjustment applied to a hit's intensity. */
2
+ export const DEGREE_DELTA = {
3
+ weak: -1,
4
+ medium: 1,
5
+ strong: 2,
6
+ extreme: 3,
7
+ };
8
+ /** Words that flip polarity when they precede an emotion word. */
9
+ export const NEGATORS = ['不', '没', '别', '无', '未', '不是', '没有', '不再', '不用', '不太'];
10
+ /** Withdrawal phrases: no explicit emotion word, but the affect is negative and weak. */
11
+ export const ABSTAIN_TERMS = ['行吧', '随便', '算了', '随你', '都行', '无所谓', '你定', '看着办', '再说吧'];
12
+ const CATEGORIES = ['joy', 'anger', 'sadness', 'fear', 'disgust', 'surprise'];
13
+ const POLARITIES = ['positive', 'negative', 'neutral'];
14
+ /**
15
+ * Validate an externally supplied lexicon. Returns human-readable problems;
16
+ * an empty array means the lexicon is usable.
17
+ */
18
+ export function validateLexicon(value) {
19
+ const errors = [];
20
+ if (typeof value !== 'object' || value === null)
21
+ return ['lexicon must be an object'];
22
+ const lex = value;
23
+ if (typeof lex.version !== 'string')
24
+ errors.push('version must be a string');
25
+ if (typeof lex.lang !== 'string')
26
+ errors.push('lang must be a string');
27
+ if (typeof lex.entries !== 'object' || lex.entries === null) {
28
+ errors.push('entries must be an object');
29
+ }
30
+ else {
31
+ for (const [word, entry] of Object.entries(lex.entries)) {
32
+ if (!word.trim())
33
+ errors.push('entries: empty word');
34
+ const e = entry;
35
+ if (!CATEGORIES.includes(e.category)) {
36
+ errors.push(`entries["${word}"].category is invalid`);
37
+ }
38
+ if (typeof e.intensity !== 'number' || e.intensity < 1 || e.intensity > 9) {
39
+ errors.push(`entries["${word}"].intensity must be 1..9`);
40
+ }
41
+ if (!POLARITIES.includes(e.polarity)) {
42
+ errors.push(`entries["${word}"].polarity is invalid`);
43
+ }
44
+ }
45
+ }
46
+ for (const key of ['fillers', 'hedges']) {
47
+ if (!Array.isArray(lex[key]))
48
+ errors.push(`${key} must be an array`);
49
+ }
50
+ if (typeof lex.vague !== 'object' || lex.vague === null)
51
+ errors.push('vague must be an object');
52
+ if (typeof lex.degree !== 'object' || lex.degree === null) {
53
+ errors.push('degree must be an object');
54
+ }
55
+ else {
56
+ for (const level of ['weak', 'medium', 'strong', 'extreme']) {
57
+ if (!Array.isArray(lex.degree[level]))
58
+ errors.push(`degree.${level} must be an array`);
59
+ }
60
+ }
61
+ return errors;
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ export const RULES = [
2
+ { id: 'R1', name: 'lexicon-hit', summary: '字典命中给出类别与基础强度' },
3
+ { id: 'R2', name: 'negation', summary: '前 3 个 token 内的否定词翻转极性/降强度' },
4
+ { id: 'R3', name: 'degree', summary: '前 2 个 token 内的程度词调整强度' },
5
+ { id: 'R4', name: 'punctuation', summary: '连续 !! ?? …… 提升 arousal' },
6
+ { id: 'R5', name: 'repetition', summary: '重复字符提升强调与强度' },
7
+ { id: 'R6', name: 'abstain', summary: '弃权短语标记为 abstain,置信度封顶 0.5,不注入' },
8
+ { id: 'R7', name: 'code-fence', summary: '代码围栏与 shell 行跳过分析' },
9
+ { id: 'R8', name: 'short-input', summary: '≤3 token 时置信度减半' },
10
+ ];
11
+ export const RULE_BUDGET = 8;
12
+ export const SEED_ENTRY_BUDGET = 120;
@@ -0,0 +1,123 @@
1
+ export const SEED_LEXICON = {
2
+ version: '1.0',
3
+ lang: 'zh',
4
+ entries: {
5
+ // joy
6
+ 开心: { category: 'joy', intensity: 5, polarity: 'positive' },
7
+ 高兴: { category: 'joy', intensity: 5, polarity: 'positive' },
8
+ 快乐: { category: 'joy', intensity: 6, polarity: 'positive' },
9
+ 愉快: { category: 'joy', intensity: 6, polarity: 'positive' },
10
+ 欣喜: { category: 'joy', intensity: 7, polarity: 'positive' },
11
+ 兴奋: { category: 'joy', intensity: 8, polarity: 'positive' },
12
+ 雀跃: { category: 'joy', intensity: 8, polarity: 'positive' },
13
+ 满足: { category: 'joy', intensity: 6, polarity: 'positive' },
14
+ 欣慰: { category: 'joy', intensity: 6, polarity: 'positive' },
15
+ 骄傲: { category: 'joy', intensity: 7, polarity: 'positive' },
16
+ 自豪: { category: 'joy', intensity: 7, polarity: 'positive' },
17
+ 踏实: { category: 'joy', intensity: 6, polarity: 'positive' },
18
+ 安心: { category: 'joy', intensity: 5, polarity: 'positive' },
19
+ 释然: { category: 'joy', intensity: 6, polarity: 'positive' },
20
+ 轻松: { category: 'joy', intensity: 5, polarity: 'positive' },
21
+ 幸福: { category: 'joy', intensity: 8, polarity: 'positive' },
22
+ 喜欢: { category: 'joy', intensity: 6, polarity: 'positive' },
23
+ 热爱: { category: 'joy', intensity: 8, polarity: 'positive' },
24
+ 享受: { category: 'joy', intensity: 6, polarity: 'positive' },
25
+ 舒畅: { category: 'joy', intensity: 6, polarity: 'positive' },
26
+ 痛快: { category: 'joy', intensity: 7, polarity: 'positive' },
27
+ 期待: { category: 'joy', intensity: 6, polarity: 'positive' },
28
+ // anger
29
+ 生气: { category: 'anger', intensity: 5, polarity: 'negative' },
30
+ 愤怒: { category: 'anger', intensity: 8, polarity: 'negative' },
31
+ 恼火: { category: 'anger', intensity: 7, polarity: 'negative' },
32
+ 烦躁: { category: 'anger', intensity: 6, polarity: 'negative' },
33
+ 不爽: { category: 'anger', intensity: 5, polarity: 'negative' },
34
+ 窝火: { category: 'anger', intensity: 7, polarity: 'negative' },
35
+ 暴躁: { category: 'anger', intensity: 7, polarity: 'negative' },
36
+ 火大: { category: 'anger', intensity: 7, polarity: 'negative' },
37
+ 抓狂: { category: 'anger', intensity: 8, polarity: 'negative' },
38
+ 崩溃: { category: 'anger', intensity: 9, polarity: 'negative' },
39
+ // sadness
40
+ 难过: { category: 'sadness', intensity: 5, polarity: 'negative' },
41
+ 伤心: { category: 'sadness', intensity: 6, polarity: 'negative' },
42
+ 失落: { category: 'sadness', intensity: 6, polarity: 'negative' },
43
+ 委屈: { category: 'sadness', intensity: 6, polarity: 'negative' },
44
+ 沮丧: { category: 'sadness', intensity: 6, polarity: 'negative' },
45
+ 低落: { category: 'sadness', intensity: 5, polarity: 'negative' },
46
+ 心疼: { category: 'sadness', intensity: 7, polarity: 'negative' },
47
+ 心累: { category: 'sadness', intensity: 7, polarity: 'negative' },
48
+ 疲惫: { category: 'sadness', intensity: 6, polarity: 'negative' },
49
+ 无力: { category: 'sadness', intensity: 6, polarity: 'negative' },
50
+ 绝望: { category: 'sadness', intensity: 9, polarity: 'negative' },
51
+ 郁闷: { category: 'sadness', intensity: 6, polarity: 'negative' },
52
+ 烦闷: { category: 'sadness', intensity: 6, polarity: 'negative' },
53
+ 压抑: { category: 'sadness', intensity: 7, polarity: 'negative' },
54
+ 憋屈: { category: 'sadness', intensity: 7, polarity: 'negative' },
55
+ 心酸: { category: 'sadness', intensity: 7, polarity: 'negative' },
56
+ 落寞: { category: 'sadness', intensity: 7, polarity: 'negative' },
57
+ 孤独: { category: 'sadness', intensity: 7, polarity: 'negative' },
58
+ // fear
59
+ 害怕: { category: 'fear', intensity: 5, polarity: 'negative' },
60
+ 恐惧: { category: 'fear', intensity: 8, polarity: 'negative' },
61
+ 焦虑: { category: 'fear', intensity: 7, polarity: 'negative' },
62
+ 紧张: { category: 'fear', intensity: 6, polarity: 'negative' },
63
+ 担心: { category: 'fear', intensity: 6, polarity: 'negative' },
64
+ 不安: { category: 'fear', intensity: 6, polarity: 'negative' },
65
+ 慌张: { category: 'fear', intensity: 6, polarity: 'negative' },
66
+ 忐忑: { category: 'fear', intensity: 6, polarity: 'negative' },
67
+ 心慌: { category: 'fear', intensity: 6, polarity: 'negative' },
68
+ 畏惧: { category: 'fear', intensity: 7, polarity: 'negative' },
69
+ 惶恐: { category: 'fear', intensity: 7, polarity: 'negative' },
70
+ // disgust
71
+ 讨厌: { category: 'disgust', intensity: 5, polarity: 'negative' },
72
+ 厌恶: { category: 'disgust', intensity: 7, polarity: 'negative' },
73
+ 反感: { category: 'disgust', intensity: 6, polarity: 'negative' },
74
+ 恶心: { category: 'disgust', intensity: 7, polarity: 'negative' },
75
+ 嫌弃: { category: 'disgust', intensity: 6, polarity: 'negative' },
76
+ 鄙视: { category: 'disgust', intensity: 7, polarity: 'negative' },
77
+ 受不了: { category: 'disgust', intensity: 6, polarity: 'negative' },
78
+ 排斥: { category: 'disgust', intensity: 6, polarity: 'negative' },
79
+ // surprise
80
+ 惊讶: { category: 'surprise', intensity: 6, polarity: 'neutral' },
81
+ 震惊: { category: 'surprise', intensity: 8, polarity: 'neutral' },
82
+ 吃惊: { category: 'surprise', intensity: 6, polarity: 'neutral' },
83
+ 意外: { category: 'surprise', intensity: 5, polarity: 'neutral' },
84
+ 诧异: { category: 'surprise', intensity: 6, polarity: 'neutral' },
85
+ 目瞪口呆: { category: 'surprise', intensity: 8, polarity: 'neutral' },
86
+ },
87
+ fillers: [
88
+ '嗯', '啊', '呃', '额', '那个', '就是', '然后', '这个',
89
+ '对吧', '是吧', '反正', '基本上', '总之', '所以说', '其实', '说实话',
90
+ ],
91
+ hedges: [
92
+ '可能', '也许', '大概', '应该', '我觉得', '好像', '似乎', '或许',
93
+ '不一定', '差不多', '算是', '感觉', '可能吧', '不确定', '大概率', '有可能',
94
+ ],
95
+ vague: {
96
+ 开心: ['欣喜', '雀跃', '兴奋'],
97
+ 难过: ['心酸', '失落', '委屈'],
98
+ 害怕: ['恐惧', '焦虑', '不安'],
99
+ 生气: ['愤怒', '恼火', '气愤'],
100
+ 不舒服: ['压抑', '烦躁', '疲惫'],
101
+ 很好: ['出色', '精彩', '优秀'],
102
+ 不好: ['糟糕', '恶劣', '拙劣'],
103
+ 喜欢: ['热爱', '着迷', '钟爱'],
104
+ 讨厌: ['厌恶', '反感', '排斥'],
105
+ 问题: ['症结', '隐患', '瓶颈'],
106
+ 东西: ['物件', '成果', '产出'],
107
+ 方面: ['维度', '层面', '环节'],
108
+ 觉得: ['认为', '判断', '确信'],
109
+ 想: ['渴望', '期待', '向往'],
110
+ 做: ['执行', '落实', '推进'],
111
+ 看: ['观察', '审视'],
112
+ 说: ['表达', '阐述'],
113
+ 快: ['迅速', '飞速'],
114
+ 大: ['巨大', '庞大'],
115
+ 多: ['大量', '充裕'],
116
+ },
117
+ degree: {
118
+ weak: ['有点', '稍微', '略微', '些许', '稍稍'],
119
+ medium: ['比较', '挺', '相当', '蛮', '还算', '很'],
120
+ strong: ['非常', '特别', '十分', '格外', '越来越'],
121
+ extreme: ['极度', '极其', '无比', '超级', '太'],
122
+ },
123
+ };
@@ -1,5 +1,10 @@
1
1
  import { rank } from './scoring.js';
2
+ import { selectDirectives } from './affect/directives.js';
2
3
  export const DEFAULT_MAX_CHARS = 1200;
4
+ /** Characters reserved for the affect section; if it does not fit, it is dropped whole. */
5
+ export const AFFECT_BUDGET = 160;
6
+ /** Only the most recent user turn counts; older samples are for evaluation, not injection. */
7
+ export const AFFECT_WINDOW_MS = 30 * 60 * 1000;
3
8
  /** Emotion → concrete behavioural instruction. Values are -2..+2. */
4
9
  export function styleDirectives(emotion) {
5
10
  const out = [];
@@ -33,6 +38,21 @@ export function buildContext(store, request, now = Date.now()) {
33
38
  if (styles.length > 0) {
34
39
  sections.push(`<style>\n${styles.map((s) => `- ${s}`).join('\n')}\n</style>`);
35
40
  }
41
+ // Affect: newest sample only, and only when it is confident and recent enough.
42
+ let affectMeta;
43
+ const [latest] = store.recentAffect(request.soulId, request.userId, now - AFFECT_WINDOW_MS);
44
+ if (latest) {
45
+ const directives = selectDirectives(latest);
46
+ if (directives.length > 0) {
47
+ const section = `<affect confidence="${latest.confidence.toFixed(2)}" analyzer="${latest.analyzer}">\n` +
48
+ `${directives.map((d) => `- ${d.text}`).join('\n')}\n</affect>`;
49
+ const usedSoFar = sections.join('\n\n').length;
50
+ if (section.length <= AFFECT_BUDGET && usedSoFar + section.length + 2 <= maxChars) {
51
+ sections.push(section);
52
+ affectMeta = { confidence: latest.confidence, directives: directives.map((d) => d.id) };
53
+ }
54
+ }
55
+ }
36
56
  const candidates = store.query({ soulId: request.soulId, userId: request.userId });
37
57
  const ranked = rank(candidates, request.query ?? '', now);
38
58
  let chars = sections.join('\n\n').length;
@@ -55,5 +75,8 @@ export function buildContext(store, request, now = Date.now()) {
55
75
  const text = sections.join('\n\n');
56
76
  if (used.length > 0)
57
77
  store.touch(used, now);
58
- return { text, used, dropped, chars: text.length };
78
+ const result = { text, used, dropped, chars: text.length };
79
+ if (affectMeta)
80
+ result.affect = affectMeta;
81
+ return result;
59
82
  }
@@ -5,23 +5,32 @@
5
5
  * Anything a host needs beyond this belongs in an adapter.
6
6
  */
7
7
  export * from './types.js';
8
- export { openSqlite } from './sqlite.js';
9
- export { buildContext, styleDirectives, DEFAULT_MAX_CHARS } from './context.js';
8
+ export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError } from './sqlite.js';
9
+ export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.js';
10
10
  export { reflect, ruleExtract, nextEmotion } from './reflect.js';
11
11
  export { rank, scoreMemory, tokenize, relevance } from './scoring.js';
12
+ export * from './affect/index.js';
13
+ export { measureStyle } from './style/measure.js';
14
+ export * from './storage/config.js';
15
+ export * from './storage/merge.js';
12
16
  import { buildContext } from './context.js';
13
17
  import { reflect, ruleExtract, nextEmotion } from './reflect.js';
14
18
  import { rank, tokenize } from './scoring.js';
19
+ import { analyzeAffect, emptyAffect, lexiconAnalyzer } from './affect/analyze.js';
15
20
  export class Soul {
16
21
  store;
17
22
  soulId;
18
23
  userId;
19
24
  name;
25
+ deviceId;
26
+ analyzer;
20
27
  constructor(options) {
21
28
  this.store = options.store;
22
29
  this.soulId = options.soulId;
23
30
  this.userId = options.userId;
24
31
  this.name = options.name ?? '小灵';
32
+ this.deviceId = options.deviceId ?? 'dev_local';
33
+ this.analyzer = options.analyzer ?? lexiconAnalyzer;
25
34
  if (!this.store.identity(this.soulId)) {
26
35
  this.store.ensureSoul({
27
36
  soulId: this.soulId,
@@ -46,9 +55,24 @@ export class Soul {
46
55
  this.store.touch(ranked.map((r) => r.memory.id));
47
56
  return ranked;
48
57
  }
49
- /** Verb 2: after each turn. Cheap and synchronous by design. */
50
- observe(turn) {
58
+ /** Pure analysis, no storage: useful for the CLI and for tests. */
59
+ analyze(text) {
60
+ try {
61
+ return this.analyzer.analyze(text);
62
+ }
63
+ catch {
64
+ return analyzeAffect(text);
65
+ }
66
+ }
67
+ /**
68
+ * Verb 2: after each turn.
69
+ *
70
+ * The analyzer may be async (a model); any failure degrades to the lexicon
71
+ * analyzer. Emotion is computed from the turn only — never from affect.
72
+ */
73
+ async observe(turn) {
51
74
  const now = turn.at ?? Date.now();
75
+ const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
52
76
  const drafts = ruleExtract([turn]);
53
77
  for (const draft of drafts) {
54
78
  this.store.put({
@@ -62,14 +86,23 @@ export class Soul {
62
86
  recallCount: 0,
63
87
  status: 'active',
64
88
  visibility: 'private',
65
- keywords: [],
89
+ keywords: tokenize(draft.content),
66
90
  createdAt: now,
91
+ updatedAt: now,
67
92
  validFrom: now,
68
93
  });
69
94
  }
95
+ if (turn.role === 'user') {
96
+ try {
97
+ this.store.saveAffect(this.soulId, this.userId, affect, now, this.deviceId);
98
+ }
99
+ catch {
100
+ // Affect is an enhancement; storage trouble must not break the session.
101
+ }
102
+ }
70
103
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
71
- this.store.saveEmotion(this.soulId, this.userId, emotion);
72
- return { written: drafts.length, emotion };
104
+ this.store.saveEmotion(this.soulId, this.userId, emotion, now);
105
+ return { written: drafts.length, emotion, affect };
73
106
  }
74
107
  /**
75
108
  * Explicit form of `observe`: the host decided this is worth keeping.
@@ -91,6 +124,7 @@ export class Soul {
91
124
  visibility: options.visibility ?? 'private',
92
125
  keywords: tokenize(content),
93
126
  createdAt: now,
127
+ updatedAt: now,
94
128
  validFrom: now,
95
129
  });
96
130
  return id;
@@ -106,4 +140,18 @@ export class Soul {
106
140
  emotion: this.store.emotion(this.soulId, this.userId),
107
141
  };
108
142
  }
143
+ async analyzeTurn(content) {
144
+ try {
145
+ const signal = await this.analyzer.analyze(content);
146
+ return { ...signal, analyzer: this.analyzer.id };
147
+ }
148
+ catch {
149
+ try {
150
+ return analyzeAffect(content);
151
+ }
152
+ catch {
153
+ return emptyAffect();
154
+ }
155
+ }
156
+ }
109
157
  }
@@ -1,9 +1,27 @@
1
1
  /**
2
2
  * SQLite implementation of SoulStore, using Node's built-in `node:sqlite`.
3
3
  * Zero npm dependencies on purpose: `npx soulbridge` must work offline.
4
+ *
5
+ * Schema versioning (ADR 0020): every open checks `schema_meta.schema_version`.
6
+ * Older databases are upgraded in place after a one-off backup; a database
7
+ * from a newer binary is refused instead of being silently written.
4
8
  */
9
+ import { copyFileSync, existsSync } from 'node:fs';
5
10
  import { DatabaseSync } from 'node:sqlite';
6
11
  import { DEFAULT_EMOTION } from './types.js';
12
+ export const SCHEMA_VERSION = 2;
13
+ export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
14
+ const BUSY_TIMEOUT_MS = 5000;
15
+ const WRITE_ATTEMPTS = 3;
16
+ export class DbNewerThanBinaryError extends Error {
17
+ code = 'DB_NEWER_THAN_BINARY';
18
+ dbVersion;
19
+ constructor(dbVersion) {
20
+ super(`database schema v${dbVersion} is newer than this binary (v${SCHEMA_VERSION})`);
21
+ this.name = 'DbNewerThanBinaryError';
22
+ this.dbVersion = dbVersion;
23
+ }
24
+ }
7
25
  const SCHEMA = `
8
26
  CREATE TABLE IF NOT EXISTS schema_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
9
27
  CREATE TABLE IF NOT EXISTS souls (
@@ -43,47 +61,111 @@ CREATE TABLE IF NOT EXISTS memories (
43
61
  );
44
62
  CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(soul_id, user_id, status);
45
63
  CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(soul_id, created_at DESC);
64
+ CREATE TABLE IF NOT EXISTS affect_samples (
65
+ soul_id TEXT NOT NULL,
66
+ user_id TEXT NOT NULL,
67
+ at INTEGER NOT NULL,
68
+ analyzer TEXT NOT NULL,
69
+ dominant TEXT,
70
+ intensity REAL NOT NULL,
71
+ valence REAL NOT NULL,
72
+ arousal REAL NOT NULL,
73
+ confidence REAL NOT NULL,
74
+ payload TEXT NOT NULL,
75
+ device_id TEXT
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_affect ON affect_samples(soul_id, user_id, at DESC);
78
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_affect_unique ON affect_samples(soul_id, user_id, at, analyzer);
46
79
  `;
47
- function toMemory(row) {
48
- return {
49
- id: String(row.id),
50
- soulId: String(row.soul_id),
51
- userId: String(row.user_id),
52
- type: row.type,
53
- content: String(row.content),
54
- importance: Number(row.importance),
55
- vividness: Number(row.vividness),
56
- recallCount: Number(row.recall_count),
57
- status: row.status,
58
- visibility: row.visibility,
59
- keywords: JSON.parse(String(row.keywords ?? '[]')),
60
- createdAt: Number(row.created_at),
61
- lastRecalledAt: row.last_recalled_at == null ? undefined : Number(row.last_recalled_at),
62
- validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
63
- validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
64
- supersededBy: row.superseded_by == null ? undefined : String(row.superseded_by),
65
- };
80
+ const sleepSync = (ms) => {
81
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
+ };
83
+ function withRetry(fn) {
84
+ let lastError;
85
+ for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
86
+ try {
87
+ return fn();
88
+ }
89
+ catch (error) {
90
+ lastError = error;
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ if (!/locked|busy/i.test(message))
93
+ throw error;
94
+ if (attempt < WRITE_ATTEMPTS - 1)
95
+ sleepSync(50 * 3 ** attempt);
96
+ }
97
+ }
98
+ throw lastError;
99
+ }
100
+ function readVersion(db) {
101
+ const row = db.prepare("SELECT v FROM schema_meta WHERE k = 'schema_version'").get();
102
+ if (!row)
103
+ return 1;
104
+ const version = Number(row.v);
105
+ return Number.isFinite(version) ? version : 1;
106
+ }
107
+ function setVersion(db, version) {
108
+ db.prepare('INSERT OR REPLACE INTO schema_meta (k, v) VALUES (?, ?)').run('schema_version', String(version));
109
+ }
110
+ function columnExists(db, table, column) {
111
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
112
+ return rows.some((row) => String(row.name) === column);
113
+ }
114
+ function ensureColumn(db, table, column, ddl) {
115
+ if (!columnExists(db, table, column))
116
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
117
+ }
118
+ /** v1 → v2: sync-ready columns (updated_at / deleted_at / device_id). */
119
+ function upgradeToV2(db) {
120
+ ensureColumn(db, 'memories', 'updated_at', 'INTEGER');
121
+ ensureColumn(db, 'memories', 'deleted_at', 'INTEGER');
122
+ ensureColumn(db, 'emotions', 'updated_at', 'INTEGER');
123
+ db.exec('UPDATE memories SET updated_at = created_at WHERE updated_at IS NULL');
124
+ db.exec('UPDATE emotions SET updated_at = 0 WHERE updated_at IS NULL');
125
+ }
126
+ function backupBeforeUpgrade(file, from) {
127
+ if (file === ':memory:' || !existsSync(file))
128
+ return;
129
+ const backup = `${file}.bak-v${from}`;
130
+ if (!existsSync(backup))
131
+ copyFileSync(file, backup);
66
132
  }
67
133
  export function openSqlite(file) {
68
134
  const db = new DatabaseSync(file);
69
135
  db.exec('PRAGMA journal_mode = WAL;');
136
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS};`);
70
137
  db.exec(SCHEMA);
71
- db.prepare('INSERT OR REPLACE INTO schema_meta (k, v) VALUES (?, ?)').run('version', '1');
138
+ const version = readVersion(db);
139
+ if (version > SCHEMA_VERSION) {
140
+ db.close();
141
+ throw new DbNewerThanBinaryError(version);
142
+ }
143
+ if (version < SCHEMA_VERSION) {
144
+ backupBeforeUpgrade(file, version);
145
+ if (version < 2)
146
+ upgradeToV2(db);
147
+ setVersion(db, SCHEMA_VERSION);
148
+ }
72
149
  const stmtPut = db.prepare(`
73
150
  INSERT INTO memories (
74
151
  id, soul_id, user_id, type, content, importance, vividness, recall_count,
75
- status, visibility, keywords, created_at, last_recalled_at, valid_from, valid_until, superseded_by
76
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ status, visibility, keywords, created_at, last_recalled_at, valid_from, valid_until,
153
+ superseded_by, updated_at, deleted_at
154
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
155
  ON CONFLICT(id) DO UPDATE SET
78
156
  content = excluded.content,
79
157
  importance = excluded.importance,
80
158
  vividness = excluded.vividness,
81
159
  status = excluded.status,
82
- superseded_by = excluded.superseded_by
160
+ superseded_by = excluded.superseded_by,
161
+ updated_at = excluded.updated_at,
162
+ deleted_at = excluded.deleted_at
83
163
  `);
84
164
  return {
85
165
  ensureSoul(identity) {
86
- db.prepare('INSERT INTO souls (soul_id, name, creator, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(soul_id) DO NOTHING').run(identity.soulId, identity.name, identity.creator ?? null, identity.createdAt);
166
+ withRetry(() => db
167
+ .prepare('INSERT INTO souls (soul_id, name, creator, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(soul_id) DO NOTHING')
168
+ .run(identity.soulId, identity.name, identity.creator ?? null, identity.createdAt));
87
169
  },
88
170
  identity(soulId) {
89
171
  const row = db.prepare('SELECT * FROM souls WHERE soul_id = ?').get(soulId);
@@ -111,36 +193,71 @@ export function openSqlite(file) {
111
193
  contentment: Number(row.contentment),
112
194
  };
113
195
  },
114
- saveEmotion(soulId, userId, emotion) {
115
- db.prepare(`INSERT INTO emotions (soul_id, user_id, passion, bond, tension, self_regard, curiosity, contentment)
116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
117
- ON CONFLICT(soul_id, user_id) DO UPDATE SET
118
- passion = excluded.passion, bond = excluded.bond, tension = excluded.tension,
119
- self_regard = excluded.self_regard, curiosity = excluded.curiosity,
120
- contentment = excluded.contentment`).run(soulId, userId, emotion.passion, emotion.bond, emotion.tension, emotion.selfRegard, emotion.curiosity, emotion.contentment);
196
+ saveEmotion(soulId, userId, emotion, now = Date.now()) {
197
+ withRetry(() => db
198
+ .prepare(`INSERT INTO emotions (soul_id, user_id, passion, bond, tension, self_regard, curiosity, contentment, updated_at)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
200
+ ON CONFLICT(soul_id, user_id) DO UPDATE SET
201
+ passion = excluded.passion, bond = excluded.bond, tension = excluded.tension,
202
+ self_regard = excluded.self_regard, curiosity = excluded.curiosity,
203
+ contentment = excluded.contentment, updated_at = excluded.updated_at`)
204
+ .run(soulId, userId, emotion.passion, emotion.bond, emotion.tension, emotion.selfRegard, emotion.curiosity, emotion.contentment, now));
121
205
  },
122
206
  put(memory) {
123
- stmtPut.run(memory.id, memory.soulId, memory.userId, memory.type, memory.content, memory.importance, memory.vividness, memory.recallCount, memory.status, memory.visibility, JSON.stringify(memory.keywords), memory.createdAt, memory.lastRecalledAt ?? null, memory.validFrom ?? null, memory.validUntil ?? null, memory.supersededBy ?? null);
207
+ withRetry(() => stmtPut.run(memory.id, memory.soulId, memory.userId, memory.type, memory.content, memory.importance, memory.vividness, memory.recallCount, memory.status, memory.visibility, JSON.stringify(memory.keywords), memory.createdAt, memory.lastRecalledAt ?? null, memory.validFrom ?? null, memory.validUntil ?? null, memory.supersededBy ?? null, memory.updatedAt ?? memory.createdAt, memory.deletedAt ?? null));
124
208
  },
125
209
  query(spec) {
126
210
  const statuses = spec.status ?? ['active'];
127
211
  const placeholders = statuses.map(() => '?').join(', ');
128
212
  const rows = db
129
213
  .prepare(`SELECT * FROM memories
130
- WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared') AND status IN (${placeholders})
214
+ WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')
215
+ AND status IN (${placeholders}) AND deleted_at IS NULL
131
216
  ORDER BY created_at DESC LIMIT ?`)
132
217
  .all(spec.soulId, spec.userId, ...statuses, spec.limit ?? 500);
133
- const memories = rows.map(toMemory);
218
+ const memories = rows.map(rowToMemory);
134
219
  return spec.types ? memories.filter((m) => spec.types.includes(m.type)) : memories;
135
220
  },
136
221
  touch(ids, now = Date.now()) {
137
222
  const stmt = db.prepare('UPDATE memories SET recall_count = recall_count + 1, last_recalled_at = ? WHERE id = ?');
138
- for (const id of ids)
139
- stmt.run(now, id);
223
+ withRetry(() => {
224
+ for (const id of ids)
225
+ stmt.run(now, id);
226
+ });
227
+ },
228
+ saveAffect(soulId, userId, signal, at, deviceId) {
229
+ withRetry(() => {
230
+ db.prepare(`INSERT OR IGNORE INTO affect_samples
231
+ (soul_id, user_id, at, analyzer, dominant, intensity, valence, arousal, confidence, payload, device_id)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(soulId, userId, at, signal.analyzer, signal.dominant ?? null, signal.intensity, signal.valence, signal.arousal, signal.confidence, JSON.stringify({ ...signal, at }), deviceId ?? null);
233
+ db.prepare('DELETE FROM affect_samples WHERE at < ?').run(at - AFFECT_RETENTION_MS);
234
+ });
235
+ },
236
+ recentAffect(soulId, userId, sinceMs) {
237
+ const rows = db
238
+ .prepare(`SELECT payload FROM affect_samples
239
+ WHERE soul_id = ? AND user_id = ? AND at >= ?
240
+ ORDER BY at DESC LIMIT 1000`)
241
+ .all(soulId, userId, sinceMs);
242
+ return rows
243
+ .map((row) => {
244
+ try {
245
+ return JSON.parse(String(row.payload));
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ })
251
+ .filter((signal) => signal !== null);
252
+ },
253
+ purgeAffect(beforeMs) {
254
+ const result = withRetry(() => db.prepare('DELETE FROM affect_samples WHERE at < ?').run(beforeMs));
255
+ return Number(result.changes ?? 0);
140
256
  },
141
257
  count(soulId, userId) {
142
258
  const row = db
143
- .prepare("SELECT COUNT(*) AS n FROM memories WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')")
259
+ .prepare(`SELECT COUNT(*) AS n FROM memories
260
+ WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared') AND deleted_at IS NULL`)
144
261
  .get(soulId, userId);
145
262
  return Number(row.n);
146
263
  },
@@ -149,3 +266,25 @@ export function openSqlite(file) {
149
266
  },
150
267
  };
151
268
  }
269
+ function rowToMemory(row) {
270
+ return {
271
+ id: String(row.id),
272
+ soulId: String(row.soul_id),
273
+ userId: String(row.user_id),
274
+ type: row.type,
275
+ content: String(row.content),
276
+ importance: Number(row.importance),
277
+ vividness: Number(row.vividness),
278
+ recallCount: Number(row.recall_count),
279
+ status: row.status,
280
+ visibility: row.visibility,
281
+ keywords: JSON.parse(String(row.keywords ?? '[]')),
282
+ createdAt: Number(row.created_at),
283
+ lastRecalledAt: row.last_recalled_at == null ? undefined : Number(row.last_recalled_at),
284
+ validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
285
+ validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
286
+ supersededBy: row.superseded_by == null ? undefined : String(row.superseded_by),
287
+ updatedAt: row.updated_at == null ? undefined : Number(row.updated_at),
288
+ deletedAt: row.deleted_at == null ? undefined : Number(row.deleted_at),
289
+ };
290
+ }