soulbridge 0.0.2 → 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.
- package/README.md +23 -3
- package/dist/packages/cli/src/cli.js +136 -36
- package/dist/packages/cli/src/llm-analyzer.js +82 -0
- package/dist/packages/core/src/affect/analyze.js +210 -0
- package/dist/packages/core/src/affect/directives.js +29 -0
- package/dist/packages/core/src/affect/index.js +4 -0
- package/dist/packages/core/src/affect/lexicon.js +62 -0
- package/dist/packages/core/src/affect/port.js +1 -0
- package/dist/packages/core/src/affect/rules.js +12 -0
- package/dist/packages/core/src/affect/seed-lexicon.js +123 -0
- package/dist/packages/core/src/context.js +24 -1
- package/dist/packages/core/src/index.js +55 -7
- package/dist/packages/core/src/sqlite.js +176 -37
- package/dist/packages/core/src/storage/config.js +83 -0
- package/dist/packages/core/src/storage/merge.js +21 -0
- package/dist/packages/core/src/style/measure.js +28 -0
- package/dist/packages/core/src/types.js +13 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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')
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
+
}
|