soulbridge 0.0.1 → 0.0.2

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
@@ -40,17 +40,24 @@ soulbridge 是它们之间那层**不属于任何一家**的记忆与人格:
40
40
 
41
41
  ## 30 秒跑起来
42
42
 
43
- 零依赖,只需要 Node ≥ 22(原生跑 TypeScript + `node:sqlite`):
43
+ 需要 Node ≥ 22.18(用到内置 `node:sqlite`):
44
44
 
45
45
  ```bash
46
- node packages/cli/src/cli.ts init
47
- node packages/cli/src/cli.ts remember --type strategy "提交前先跑 make check,能省一轮 CI"
48
- node packages/cli/src/cli.ts context --query "要不要先跑测试"
49
- node packages/cli/src/cli.ts demo # 起一个 soul,跑完整 context→observe→reflect→context
46
+ npx soulbridge init
47
+ npx soulbridge remember --type strategy "提交前先跑 make check,能省一轮 CI"
48
+ npx soulbridge context --query "要不要先跑测试"
49
+ npx soulbridge demo # 起一个 soul,跑完整 context→observe→reflect→context
50
50
  ```
51
51
 
52
52
  不需要模型、不需要网络、不需要数据库服务。
53
53
 
54
+ 从源码开发时:
55
+
56
+ ```bash
57
+ npm install && npm run build
58
+ node packages/cli/src/cli.ts demo # 直接跑 TypeScript(Node 22.18+ 原生支持)
59
+ ```
60
+
54
61
  ## 架构
55
62
 
56
63
  ```
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal CLI. No framework, no dependencies.
4
+ *
5
+ * soulbridge init
6
+ * soulbridge remember "以后提交前先跑 make check" --type strategy --importance 8
7
+ * soulbridge context --query "提交前要做什么"
8
+ * soulbridge recall "测试"
9
+ * soulbridge reflect --text "..." (or --file transcript.txt)
10
+ * soulbridge stats
11
+ * soulbridge demo
12
+ */
13
+ import { mkdirSync, readFileSync, rmSync } from 'node:fs';
14
+ import { dirname, resolve } from 'node:path';
15
+ // node:sqlite prints an ExperimentalWarning the moment it loads. Replace the default
16
+ // printer with a filter: the CLI's own output stays clean, other warnings still show.
17
+ // Must run before the dynamic import below, which is why that import is dynamic.
18
+ process.removeAllListeners('warning');
19
+ process.on('warning', (warning) => {
20
+ if (warning.message.includes('SQLite is an experimental feature'))
21
+ return;
22
+ process.stderr.write(`${warning.name}: ${warning.message}\n`);
23
+ });
24
+ const { openSqlite, Soul } = await import('../../core/src/index.js');
25
+ function parse(argv) {
26
+ const [cmd = 'help', ...rest] = argv;
27
+ const positional = [];
28
+ const flags = {};
29
+ for (let i = 0; i < rest.length; i += 1) {
30
+ const arg = rest[i];
31
+ if (!arg.startsWith('--')) {
32
+ positional.push(arg);
33
+ continue;
34
+ }
35
+ const key = arg.slice(2);
36
+ const next = rest[i + 1];
37
+ if (next !== undefined && !next.startsWith('--')) {
38
+ flags[key] = next;
39
+ i += 1;
40
+ }
41
+ else {
42
+ flags[key] = 'true';
43
+ }
44
+ }
45
+ return { cmd, positional, flags };
46
+ }
47
+ function dbFile() {
48
+ const file = process.env.SOULBRIDGE_DB ?? resolve(process.cwd(), '.soulbridge', 'soul.db');
49
+ mkdirSync(dirname(file), { recursive: true });
50
+ return file;
51
+ }
52
+ function open(file, createdAt) {
53
+ const target = file ?? dbFile();
54
+ const store = openSqlite(target);
55
+ const soul = new Soul({
56
+ store,
57
+ soulId: process.env.SOULBRIDGE_SOUL ?? 'xiaoling',
58
+ userId: process.env.SOULBRIDGE_USER ?? 'local',
59
+ name: process.env.SOULBRIDGE_NAME ?? '小灵',
60
+ createdAt,
61
+ });
62
+ return { soul, file: target };
63
+ }
64
+ function out(value) {
65
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
66
+ }
67
+ async function demo() {
68
+ const dir = resolve(process.cwd(), '.soulbridge');
69
+ mkdirSync(dir, { recursive: true });
70
+ const file = resolve(dir, 'demo.db');
71
+ for (const suffix of ['', '-wal', '-shm'])
72
+ rmSync(`${file}${suffix}`, { force: true });
73
+ // Backdate the birthday so the demo shows a relationship that has time in it.
74
+ const seed = openSqlite(file);
75
+ seed.ensureSoul({ soulId: 'xiaoling', name: '小灵', createdAt: Date.now() - 3 * 86_400_000 });
76
+ seed.close();
77
+ const soul = new Soul({ store: openSqlite(file), soulId: 'xiaoling', userId: 'demo', name: '小灵' });
78
+ process.stdout.write('=== 第 1 次会话(刚认识,还没有记忆)===\n');
79
+ process.stdout.write(`${soul.context().text}\n\n`);
80
+ process.stdout.write('=== 会话结束,反思 ===\n');
81
+ const result = await soul.reflect([
82
+ { role: 'user', content: '以后提交前先跑 make check,别等 CI 挂了再修' },
83
+ { role: 'assistant', content: '好,记住了。' },
84
+ { role: 'user', content: '我喜欢简洁的回答,不要铺垫' },
85
+ { role: 'user', content: '不对,这个方案是错的' },
86
+ ]);
87
+ process.stdout.write(`写入 ${result.memories} 条记忆,情绪 ${JSON.stringify(result.emotion)}\n\n`);
88
+ process.stdout.write('=== 第 2 次会话:开场(无查询,按重要度注入全部)===\n');
89
+ process.stdout.write(`${soul.context().text}\n\n`);
90
+ process.stdout.write('=== 同一会话中,用户问「提交前要做什么」(有查询,只注入相关的)===\n');
91
+ process.stdout.write(`${soul.context({ query: '提交前要做什么' }).text}\n\n`);
92
+ process.stdout.write('=== stats ===\n');
93
+ out(soul.stats());
94
+ process.stdout.write(`\n数据库:${file}\n`);
95
+ }
96
+ async function main() {
97
+ const { cmd, positional, flags } = parse(process.argv.slice(2));
98
+ switch (cmd) {
99
+ case 'init': {
100
+ const { soul, file } = open();
101
+ out({ ok: true, db: file, ...soul.stats() });
102
+ return;
103
+ }
104
+ case 'remember': {
105
+ const content = positional.join(' ').trim();
106
+ if (!content)
107
+ throw new Error('usage: soulbridge remember "内容" [--type T] [--importance N]');
108
+ const { soul } = open();
109
+ const id = soul.remember(content, {
110
+ type: flags.type ?? 'fact',
111
+ importance: flags.importance ? Number(flags.importance) : undefined,
112
+ });
113
+ out({ ok: true, id });
114
+ return;
115
+ }
116
+ case 'context': {
117
+ const { soul } = open();
118
+ const result = soul.context({
119
+ query: flags.query,
120
+ maxChars: flags.max ? Number(flags.max) : undefined,
121
+ });
122
+ out(result);
123
+ return;
124
+ }
125
+ case 'recall': {
126
+ const query = positional.join(' ').trim();
127
+ if (!query)
128
+ throw new Error('usage: soulbridge recall "查询" [--limit N]');
129
+ const { soul } = open();
130
+ const hits = soul.recall(query, flags.limit ? Number(flags.limit) : 5);
131
+ out(hits.map((h) => ({ id: h.memory.id, type: h.memory.type, content: h.memory.content, score: Number(h.score.toFixed(3)) })));
132
+ return;
133
+ }
134
+ case 'observe': {
135
+ const content = positional.join(' ').trim();
136
+ if (!content)
137
+ throw new Error('usage: soulbridge observe "一句话"');
138
+ const { soul } = open();
139
+ out(soul.observe({ role: 'user', content }));
140
+ return;
141
+ }
142
+ case 'reflect': {
143
+ const text = flags.file ? readFileSync(flags.file, 'utf8') : (flags.text ?? '');
144
+ if (!text.trim())
145
+ throw new Error('usage: soulbridge reflect --text "..." | --file transcript.txt');
146
+ const turns = text
147
+ .split(/\r?\n/)
148
+ .map((line) => line.trim())
149
+ .filter(Boolean)
150
+ .map((line) => {
151
+ const match = line.match(/^(user|assistant|助手|用户)\s*[::]\s*(.*)$/i);
152
+ if (!match)
153
+ return { role: 'user', content: line };
154
+ const role = /^(assistant|助手)$/i.test(match[1]) ? 'assistant' : 'user';
155
+ return { role, content: match[2] };
156
+ });
157
+ const { soul } = open();
158
+ out(await soul.reflect(turns));
159
+ return;
160
+ }
161
+ case 'stats': {
162
+ const { soul, file } = open();
163
+ out({ db: file, ...soul.stats() });
164
+ return;
165
+ }
166
+ case 'demo':
167
+ await demo();
168
+ return;
169
+ default:
170
+ process.stdout.write([
171
+ 'soulbridge — 给任何 agent 装上一个人格层',
172
+ '',
173
+ 'commands:',
174
+ ' init 初始化数据库',
175
+ ' remember <text> [--type T] 显式记住一条',
176
+ ' context [--query Q] [--max N] 生成注入文本',
177
+ ' recall <q> [--limit N] 召回',
178
+ ' observe <text> 观察一轮',
179
+ ' reflect --text|--file 会话反思',
180
+ ' stats 看状态',
181
+ ' demo 端到端演示(无需模型)',
182
+ '',
183
+ 'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME',
184
+ '',
185
+ ].join('\n'));
186
+ }
187
+ }
188
+ main().catch((error) => {
189
+ process.stderr.write(`soulbridge: ${error instanceof Error ? error.message : String(error)}\n`);
190
+ process.exitCode = 1;
191
+ });
@@ -0,0 +1,59 @@
1
+ import { rank } from './scoring.js';
2
+ export const DEFAULT_MAX_CHARS = 1200;
3
+ /** Emotion → concrete behavioural instruction. Values are -2..+2. */
4
+ export function styleDirectives(emotion) {
5
+ const out = [];
6
+ if (emotion.tension > 1.0)
7
+ out.push('回答前先确认理解,避免草率结论');
8
+ if (emotion.selfRegard < -0.5)
9
+ out.push('对自己的输出多检查一遍');
10
+ if (emotion.curiosity > 0.5)
11
+ out.push('可以主动提问,探索延伸');
12
+ if (emotion.bond > 0.8)
13
+ out.push('像朋友一样聊天,少一点客套');
14
+ if (emotion.contentment > 1.2)
15
+ out.push('不要过度确认,直接给结论');
16
+ return out;
17
+ }
18
+ function timeTogether(createdAt, now) {
19
+ const days = Math.floor((now - createdAt) / 86_400_000);
20
+ if (days <= 0)
21
+ return '今天是第一次见面';
22
+ return `你已经和用户相处了 ${days} 天`;
23
+ }
24
+ export function buildContext(store, request, now = Date.now()) {
25
+ const identity = store.identity(request.soulId);
26
+ const name = identity?.name ?? request.soulId;
27
+ const emotion = store.emotion(request.soulId, request.userId);
28
+ const maxChars = request.maxChars ?? DEFAULT_MAX_CHARS;
29
+ const sections = [];
30
+ const since = identity ? timeTogether(identity.createdAt, now) : '你刚开始认识这个用户';
31
+ sections.push(`<soul name="${name}">\n你是${name},一个持续成长的伙伴。${since}。\n</soul>`);
32
+ const styles = styleDirectives(emotion);
33
+ if (styles.length > 0) {
34
+ sections.push(`<style>\n${styles.map((s) => `- ${s}`).join('\n')}\n</style>`);
35
+ }
36
+ const candidates = store.query({ soulId: request.soulId, userId: request.userId });
37
+ const ranked = rank(candidates, request.query ?? '', now);
38
+ let chars = sections.join('\n\n').length;
39
+ let dropped = 0;
40
+ const lines = [];
41
+ const used = [];
42
+ for (const { memory } of ranked) {
43
+ const line = `- [${memory.type}] ${memory.content}`;
44
+ const cost = line.length + 1 + 10; // line + newline + section overhead share
45
+ if (chars + cost > maxChars) {
46
+ dropped += 1;
47
+ continue;
48
+ }
49
+ lines.push(line);
50
+ used.push(memory.id);
51
+ chars += cost;
52
+ }
53
+ if (lines.length > 0)
54
+ sections.push(`<memories>\n${lines.join('\n')}\n</memories>`);
55
+ const text = sections.join('\n\n');
56
+ if (used.length > 0)
57
+ store.touch(used, now);
58
+ return { text, used, dropped, chars: text.length };
59
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * soulbridge core — the personality layer.
3
+ *
4
+ * Public surface is deliberately tiny: a facade (`Soul`) over four verbs.
5
+ * Anything a host needs beyond this belongs in an adapter.
6
+ */
7
+ export * from './types.js';
8
+ export { openSqlite } from './sqlite.js';
9
+ export { buildContext, styleDirectives, DEFAULT_MAX_CHARS } from './context.js';
10
+ export { reflect, ruleExtract, nextEmotion } from './reflect.js';
11
+ export { rank, scoreMemory, tokenize, relevance } from './scoring.js';
12
+ import { buildContext } from './context.js';
13
+ import { reflect, ruleExtract, nextEmotion } from './reflect.js';
14
+ import { rank, tokenize } from './scoring.js';
15
+ export class Soul {
16
+ store;
17
+ soulId;
18
+ userId;
19
+ name;
20
+ constructor(options) {
21
+ this.store = options.store;
22
+ this.soulId = options.soulId;
23
+ this.userId = options.userId;
24
+ this.name = options.name ?? '小灵';
25
+ if (!this.store.identity(this.soulId)) {
26
+ this.store.ensureSoul({
27
+ soulId: this.soulId,
28
+ name: this.name,
29
+ createdAt: options.createdAt ?? Date.now(),
30
+ });
31
+ }
32
+ }
33
+ /** Verb 1: session start. */
34
+ context(request = {}) {
35
+ return buildContext(this.store, {
36
+ soulId: this.soulId,
37
+ userId: this.userId,
38
+ query: request.query,
39
+ maxChars: request.maxChars,
40
+ });
41
+ }
42
+ /** Verb 3: on demand. */
43
+ recall(query, limit = 5) {
44
+ const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
45
+ const ranked = rank(memories, query, Date.now()).slice(0, limit);
46
+ this.store.touch(ranked.map((r) => r.memory.id));
47
+ return ranked;
48
+ }
49
+ /** Verb 2: after each turn. Cheap and synchronous by design. */
50
+ observe(turn) {
51
+ const now = turn.at ?? Date.now();
52
+ const drafts = ruleExtract([turn]);
53
+ for (const draft of drafts) {
54
+ this.store.put({
55
+ id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
56
+ soulId: this.soulId,
57
+ userId: this.userId,
58
+ type: draft.type,
59
+ content: draft.content,
60
+ importance: draft.importance,
61
+ vividness: 0.8,
62
+ recallCount: 0,
63
+ status: 'active',
64
+ visibility: 'private',
65
+ keywords: [],
66
+ createdAt: now,
67
+ validFrom: now,
68
+ });
69
+ }
70
+ 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 };
73
+ }
74
+ /**
75
+ * Explicit form of `observe`: the host decided this is worth keeping.
76
+ * Same write path, no extraction heuristics.
77
+ */
78
+ remember(content, options = {}) {
79
+ const now = Date.now();
80
+ const id = `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
81
+ this.store.put({
82
+ id,
83
+ soulId: this.soulId,
84
+ userId: this.userId,
85
+ type: options.type ?? 'fact',
86
+ content,
87
+ importance: options.importance ?? 6,
88
+ vividness: 0.9,
89
+ recallCount: 0,
90
+ status: 'active',
91
+ visibility: options.visibility ?? 'private',
92
+ keywords: tokenize(content),
93
+ createdAt: now,
94
+ validFrom: now,
95
+ });
96
+ return id;
97
+ }
98
+ /** Verb 4: session end. */
99
+ async reflect(turns, llm) {
100
+ return reflect(this.store, { soulId: this.soulId, userId: this.userId, turns, llm });
101
+ }
102
+ stats() {
103
+ return {
104
+ name: this.name,
105
+ memories: this.store.count(this.soulId, this.userId),
106
+ emotion: this.store.emotion(this.soulId, this.userId),
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reflection: turn a finished session into durable memory + an emotion update.
3
+ *
4
+ * `core` never calls a model itself. If the caller passes an `LlmFn`, richer
5
+ * extraction is possible later; today the rule path is the default, which keeps
6
+ * the whole loop runnable offline.
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { tokenize } from './scoring.js';
10
+ /** Cheap, synchronous, no model. Deliberately conservative: false positives cost trust. */
11
+ const RULES = [
12
+ { re: /(记住|别忘了|remember|don'?t forget)/i, type: 'fact', importance: 7 },
13
+ { re: /(我喜欢|我习惯|我偏好|我倾向|i prefer|i like)/i, type: 'trait', importance: 6 },
14
+ { re: /(以后|下次|每次|从现在起|from now on|always)/i, type: 'strategy', importance: 7 },
15
+ { re: /(我决定|就这么定|原则是|i decided|my rule)/i, type: 'cognition', importance: 7 },
16
+ ];
17
+ const CORRECTION = /(不对|不是这样|错了|你搞错|wrong|that'?s not right|incorrect)/i;
18
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
19
+ const round2 = (n) => Math.round(n * 100) / 100;
20
+ export function ruleExtract(turns) {
21
+ const drafts = [];
22
+ for (const turn of turns) {
23
+ if (turn.role !== 'user')
24
+ continue;
25
+ for (const rule of RULES) {
26
+ if (rule.re.test(turn.content)) {
27
+ drafts.push({ type: rule.type, content: turn.content.trim(), importance: rule.importance });
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ return drafts;
33
+ }
34
+ /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
35
+ export function nextEmotion(current, turns) {
36
+ const corrected = turns.some((t) => t.role === 'user' && CORRECTION.test(t.content));
37
+ if (corrected) {
38
+ return {
39
+ passion: round2(clamp(current.passion - 0.1, -2, 2)),
40
+ bond: round2(current.bond),
41
+ tension: round2(clamp(current.tension + 0.3, -2, 2)),
42
+ selfRegard: round2(clamp(current.selfRegard - 0.05, -2, 2)),
43
+ curiosity: round2(current.curiosity),
44
+ contentment: round2(clamp(current.contentment - 0.05, -2, 2)),
45
+ };
46
+ }
47
+ return {
48
+ passion: round2(current.passion),
49
+ bond: round2(clamp(current.bond + 0.02, -2, 2)),
50
+ tension: round2(clamp(current.tension * 0.85, -2, 2)),
51
+ selfRegard: round2(current.selfRegard),
52
+ curiosity: round2(current.curiosity),
53
+ contentment: round2(current.contentment),
54
+ };
55
+ }
56
+ export async function reflect(store, options) {
57
+ const now = options.now ?? Date.now();
58
+ const drafts = ruleExtract(options.turns);
59
+ const existing = store.query({ soulId: options.soulId, userId: options.userId });
60
+ const seen = new Set(existing.map((m) => m.content));
61
+ let written = 0;
62
+ for (const draft of drafts) {
63
+ if (seen.has(draft.content))
64
+ continue;
65
+ seen.add(draft.content);
66
+ const memory = {
67
+ id: `mem_${randomUUID().slice(0, 8)}`,
68
+ soulId: options.soulId,
69
+ userId: options.userId,
70
+ type: draft.type,
71
+ content: draft.content,
72
+ importance: draft.importance,
73
+ vividness: 0.8,
74
+ recallCount: 0,
75
+ status: 'active',
76
+ visibility: draft.type === 'relationship' ? 'private' : 'private',
77
+ keywords: tokenize(draft.content),
78
+ createdAt: now,
79
+ validFrom: now,
80
+ };
81
+ store.put(memory);
82
+ written += 1;
83
+ }
84
+ const emotion = nextEmotion(store.emotion(options.soulId, options.userId), options.turns);
85
+ store.saveEmotion(options.soulId, options.userId, emotion);
86
+ return { memories: written, superseded: 0, emotion };
87
+ }
@@ -0,0 +1,67 @@
1
+ /** A memory's relevance halves every 30 days without being recalled. */
2
+ export const RECENCY_HALF_LIFE_DAYS = 30;
3
+ export const WEIGHTS = {
4
+ relevance: 0.35,
5
+ importance: 0.25,
6
+ recency: 0.2,
7
+ vividness: 0.15,
8
+ recall: 0.05,
9
+ };
10
+ /**
11
+ * CJK text has no spaces, so single characters are too coarse and the whole
12
+ * sentence is too fine: use character bigrams. Latin runs become words.
13
+ */
14
+ export function tokenize(input) {
15
+ const lower = input.toLowerCase();
16
+ const out = new Set();
17
+ for (const word of lower.match(/[a-z0-9_]{2,}/g) ?? [])
18
+ out.add(word);
19
+ for (const run of lower.match(/[\u4e00-\u9fa5]+/g) ?? []) {
20
+ if (run.length === 1) {
21
+ out.add(run);
22
+ continue;
23
+ }
24
+ for (let i = 0; i < run.length - 1; i += 1)
25
+ out.add(run.slice(i, i + 2));
26
+ }
27
+ return [...out];
28
+ }
29
+ export function relevance(queryTokens, memory) {
30
+ if (queryTokens.length === 0)
31
+ return 0;
32
+ const pool = new Set([
33
+ ...tokenize(memory.content),
34
+ ...memory.keywords.map((k) => k.toLowerCase()),
35
+ ]);
36
+ let hit = 0;
37
+ for (const token of queryTokens)
38
+ if (pool.has(token))
39
+ hit += 1;
40
+ return hit / queryTokens.length;
41
+ }
42
+ export function recency(reference, now) {
43
+ const days = Math.max(0, (now - reference) / 86_400_000);
44
+ return Math.pow(0.5, days / RECENCY_HALF_LIFE_DAYS);
45
+ }
46
+ const clamp01 = (n) => Math.min(1, Math.max(0, n));
47
+ export function scoreMemory(memory, queryTokens, now) {
48
+ const rel = relevance(queryTokens, memory);
49
+ const imp = clamp01(memory.importance / 10);
50
+ const rec = recency(memory.lastRecalledAt ?? memory.createdAt, now);
51
+ const viv = clamp01(memory.vividness);
52
+ const recall = clamp01(memory.recallCount / 10);
53
+ return (rel * WEIGHTS.relevance +
54
+ imp * WEIGHTS.importance +
55
+ rec * WEIGHTS.recency +
56
+ viv * WEIGHTS.vividness +
57
+ recall * WEIGHTS.recall);
58
+ }
59
+ export function rank(memories, query, now) {
60
+ const tokens = tokenize(query);
61
+ // A query is a selection: with one, a memory that shares no token is noise,
62
+ // no matter how important it is. Without one (session start), rank by weight alone.
63
+ const pool = tokens.length === 0 ? memories : memories.filter((m) => relevance(tokens, m) > 0);
64
+ return pool
65
+ .map((memory) => ({ memory, score: scoreMemory(memory, tokens, now) }))
66
+ .sort((a, b) => b.score - a.score);
67
+ }