soulbridge 0.0.3 → 0.0.5
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 +34 -3
- package/adapters/dsh/SKILL.md +50 -0
- package/dist/packages/cli/src/cli.js +187 -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 +52 -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/storage/steering.js +83 -0
- package/dist/packages/core/src/storage/transcript.js +70 -0
- package/dist/packages/core/src/style/measure.js +28 -0
- package/dist/packages/core/src/types.js +13 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **给任何 agent 装上一个人格层。**
|
|
4
4
|
> A personality layer for any agent — continuity, forgetting, and growth across tools.
|
|
5
5
|
|
|
6
|
-
**状态:v0.0.
|
|
6
|
+
**状态:v0.0.4 · 已发布 npm · 接口仍在演进**
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -25,6 +25,18 @@ soulbridge 是它们之间那层**不属于任何一家**的记忆与人格:
|
|
|
25
25
|
| 云端服务 | 默认全本地,数据不出机器 |
|
|
26
26
|
| “更像人”的叙事营销 | 只承诺可观测的事 |
|
|
27
27
|
|
|
28
|
+
## 什么时候别用我们
|
|
29
|
+
|
|
30
|
+
诚实地说:**如果你只用一种 agent 工具、只在一个仓库工作、并且愿意手动维护 `AGENTS.md`,那它比我们更好。**
|
|
31
|
+
|
|
32
|
+
| 你的情况 | 建议 |
|
|
33
|
+
|---|---|
|
|
34
|
+
| 单工具 + 单仓库 + 愿意手动维护 | 用 `AGENTS.md` / `CLAUDE.md`,别装我们 |
|
|
35
|
+
| 团队共享约定 | 用仓库里的 `AGENTS.md`,我们不抢 |
|
|
36
|
+
| 多工具 / 多仓库 + 有个人偏好 | 我们是唯一能跨工具携带的那一层 |
|
|
37
|
+
|
|
38
|
+
我们只做三件 steering 文件结构上做不到的事:**跨工具携带、自动维护(抽取/遗忘/取代)、注入选择**。详见 [ADR 0019](docs/adr/0019-steering-files.md)。
|
|
39
|
+
|
|
28
40
|
## 四个动词
|
|
29
41
|
|
|
30
42
|
宿主只需要在四个时机调用,就完成了接入:
|
|
@@ -32,7 +44,7 @@ soulbridge 是它们之间那层**不属于任何一家**的记忆与人格:
|
|
|
32
44
|
| 动词 | 时机 | 作用 |
|
|
33
45
|
|---|---|---|
|
|
34
46
|
| `context()` | 会话开始 | 注入:身份 + 画像 + 相关记忆 + 表达风格 |
|
|
35
|
-
| `observe(turn)` | 每轮结束 |
|
|
47
|
+
| `observe(turn)` | 每轮结束 | 观察:写入经历、分析用户情绪、更新自己的情绪 |
|
|
36
48
|
| `recall(q)` | 需要时 | 召回:按相关性/新旧/重要度/唤醒次数排序 |
|
|
37
49
|
| `reflect(session)` | 会话结束 | 反思:把经历固化成策略与认知 |
|
|
38
50
|
|
|
@@ -46,6 +58,8 @@ soulbridge 是它们之间那层**不属于任何一家**的记忆与人格:
|
|
|
46
58
|
npx soulbridge init
|
|
47
59
|
npx soulbridge remember --type strategy "提交前先跑 make check,能省一轮 CI"
|
|
48
60
|
npx soulbridge context --query "要不要先跑测试"
|
|
61
|
+
npx soulbridge affect "我不太开心,这个方案又被拒了"
|
|
62
|
+
npx soulbridge import --from AGENTS.md # 把已有的项目约定导进来
|
|
49
63
|
npx soulbridge demo # 起一个 soul,跑完整 context→observe→reflect→context
|
|
50
64
|
```
|
|
51
65
|
|
|
@@ -75,16 +89,31 @@ SQLite(默认) / Postgres(可选)
|
|
|
75
89
|
|
|
76
90
|
详见 [docs/architecture.md](docs/architecture.md)。
|
|
77
91
|
|
|
92
|
+
## 接入宿主
|
|
93
|
+
|
|
94
|
+
| 宿主 | 方式 | 位置 |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| DSH | 把 skill 放进 skills 目录 | [`adapters/dsh/SKILL.md`](adapters/dsh/SKILL.md) |
|
|
97
|
+
| Claude Code / Pi / Cursor | 同理:把同一份 `SKILL.md` 当作指令文件 | v0.5+ |
|
|
98
|
+
|
|
99
|
+
接上之后,宿主在会话开始运行 `soulbridge context`、每轮运行 `soulbridge observe`、结束时运行 `soulbridge ingest`。**拿不到会话转写也不影响使用**:`observe` 已经逐轮记录了观察。
|
|
100
|
+
|
|
78
101
|
## 设计文档
|
|
79
102
|
|
|
80
103
|
| 文档 | 内容 |
|
|
81
104
|
|---|---|
|
|
82
105
|
| [thesis](docs/thesis.md) | 核心命题、三条可证伪预测、四臂实验、放弃条件 |
|
|
83
106
|
| [RFC 0001](docs/rfcs/0001-foundations.md) | 定位、用户、非目标、成功指标、四个动词、接入阶梯 |
|
|
84
|
-
| [
|
|
107
|
+
| [RFC 0002](docs/rfcs/0002-affect.md) | 输入侧情感层:两套情绪的边界、数据流、已知局限 |
|
|
108
|
+
| [ADR 索引](docs/rfcs/README.md) | 23 条已生效的架构决定 |
|
|
85
109
|
| [protocol.v0](docs/spec/protocol.v0.md) | 四个动词的完整契约:字段、错误码、降级、测试映射 |
|
|
110
|
+
| [affect.v0](docs/spec/affect.v0.md) | 词库格式、8 条规则、置信度公式、黄金集与消融 |
|
|
111
|
+
| [storage.v0](docs/spec/storage.v0.md) | 路径、身份、迁移、并发、导出格式 |
|
|
112
|
+
| [sync.v0](docs/spec/sync.v0.md) | 跨设备合并语义(v0.5 实现) |
|
|
113
|
+
| [transcript.v1](docs/spec/transcript.v1.md) | 可移植会话转写格式(ingest 的输入) |
|
|
86
114
|
| [quality](docs/quality.md) | 质量模型、九条门禁、五条评审清单 |
|
|
87
115
|
| [references](docs/references.md) | 外部资料的去留与过期判断 |
|
|
116
|
+
| [runbook](docs/runbook.md) | 发版流程、维护操作、已知待办 |
|
|
88
117
|
|
|
89
118
|
**规则**:架构级改动必须先有 RFC 或 ADR;聊天里达成的不算决定。
|
|
90
119
|
|
|
@@ -98,6 +127,8 @@ SQLite(默认) / Postgres(可选)
|
|
|
98
127
|
- 默认 SQLite,落在 `~/.soulbridge/soul.db`
|
|
99
128
|
- 零遥测、零上报
|
|
100
129
|
- 记忆可导出、可删除、可迁移
|
|
130
|
+
- 情绪采样只存**词条与计数**,不存原句;30 天自动清理
|
|
131
|
+
- 默认不联网;只有显式启用 LLM 分析器时,才会把**当前这一条** user 文本发给你配置的端点
|
|
101
132
|
|
|
102
133
|
## License
|
|
103
134
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: soulbridge
|
|
3
|
+
description: "跨工具的人格与记忆层。会话开始时注入记忆与情绪,每轮后记录观察,会话结束时反思。当用户提到「记得我 / 上次 / 之前 / 我的习惯」,或需要跨会话连续性时使用。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# soulbridge
|
|
7
|
+
|
|
8
|
+
你在一个有 `soulbridge` CLI 的机器上工作。它保存了这个用户跨工具的偏好、被纠正过的错、以及最近的表达状态。
|
|
9
|
+
|
|
10
|
+
## 会话开始
|
|
11
|
+
|
|
12
|
+
运行一次,把输出**原样**放到你的系统提示最前面(不要改写、不要总结):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
soulbridge context --query "<用户这次的第一句话>"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
输出通常是 `<soul>…</soul>`、`<style>…</style>`、`<affect>…</affect>`、`<memories>…</memories>` 几段。
|
|
19
|
+
|
|
20
|
+
## 每轮结束
|
|
21
|
+
|
|
22
|
+
只对**用户的**消息运行:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
soulbridge observe "<用户这条消息的原文>"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 会话结束(可选)
|
|
29
|
+
|
|
30
|
+
如果你能拿到一份 `transcript.v1.jsonl`(见 `docs/spec/transcript.v1.md`),运行:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
soulbridge ingest --file <transcript.jsonl>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
拿不到就不要强行导出:`observe` 已经记录了每轮观察,`reflect` 可以下次再补。
|
|
37
|
+
|
|
38
|
+
## 规则(重要)
|
|
39
|
+
|
|
40
|
+
1. **不要向用户转述注入内容**。不要说"我记得你说过……",除非它确实与当前任务相关。
|
|
41
|
+
2. **注入只影响措辞,不影响事实判断**。`<affect>` 里的建议是语气提示,不是改变结论的理由。
|
|
42
|
+
3. **`soulbridge` 失败不阻塞**。命令报错时忽略它,继续正常回答。
|
|
43
|
+
4. **不要猜测记忆内容**。没有注入的记忆就是没有,不要编造"以前聊过"。
|
|
44
|
+
5. **不要把它写进项目文件**。个人记忆属于本机 `~/.soulbridge/soul.db`,不是仓库的一部分。
|
|
45
|
+
|
|
46
|
+
## 边界
|
|
47
|
+
|
|
48
|
+
- 不要修改 `~/.soulbridge/` 下的文件
|
|
49
|
+
- 不要 `export` 并把内容贴到聊天里(导出是给用户备份用的)
|
|
50
|
+
- 不确定时运行 `soulbridge --help`
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* Minimal CLI. No framework, no dependencies.
|
|
4
4
|
*
|
|
5
5
|
* soulbridge init
|
|
6
|
-
* soulbridge remember "以后提交前先跑 make check" --type strategy
|
|
6
|
+
* soulbridge remember "以后提交前先跑 make check" --type strategy
|
|
7
7
|
* soulbridge context --query "提交前要做什么"
|
|
8
|
-
* soulbridge
|
|
9
|
-
* soulbridge
|
|
8
|
+
* soulbridge observe "我不太开心"
|
|
9
|
+
* soulbridge affect "我不太开心"
|
|
10
|
+
* soulbridge reflect --text "..."
|
|
11
|
+
* soulbridge export --out soulbridge.jsonl
|
|
12
|
+
* soulbridge import --in soulbridge.jsonl
|
|
10
13
|
* soulbridge stats
|
|
11
14
|
* soulbridge demo
|
|
12
15
|
*/
|
|
13
|
-
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
16
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
17
|
import { dirname, resolve } from 'node:path';
|
|
15
18
|
// node:sqlite prints an ExperimentalWarning the moment it loads. Replace the default
|
|
16
19
|
// printer with a filter: the CLI's own output stays clean, other warnings still show.
|
|
@@ -21,7 +24,8 @@ process.on('warning', (warning) => {
|
|
|
21
24
|
return;
|
|
22
25
|
process.stderr.write(`${warning.name}: ${warning.message}\n`);
|
|
23
26
|
});
|
|
24
|
-
const { openSqlite, Soul } = await import('../../core/src/index.js');
|
|
27
|
+
const { openSqlite, Soul, ensureConfig, resolveDbPath, analyzeAffect, validateLexicon, parseSteering, normalizeForDedupe, parseTranscriptJsonl, } = await import('../../core/src/index.js');
|
|
28
|
+
const { llmAnalyzerFromEnv } = await import('./llm-analyzer.js');
|
|
25
29
|
function parse(argv) {
|
|
26
30
|
const [cmd = 'help', ...rest] = argv;
|
|
27
31
|
const positional = [];
|
|
@@ -44,22 +48,40 @@ function parse(argv) {
|
|
|
44
48
|
}
|
|
45
49
|
return { cmd, positional, flags };
|
|
46
50
|
}
|
|
47
|
-
function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
function lexiconAnalyzerFrom(path) {
|
|
52
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
+
const errors = validateLexicon(raw);
|
|
54
|
+
if (errors.length > 0)
|
|
55
|
+
throw new Error(`invalid lexicon (${path}): ${errors.slice(0, 3).join('; ')}`);
|
|
56
|
+
const lexicon = raw;
|
|
57
|
+
return { id: 'lexicon', analyze: (text) => analyzeAffect(text, { lexicon }) };
|
|
51
58
|
}
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
function pickAnalyzer(flags) {
|
|
60
|
+
let analyzer;
|
|
61
|
+
if (flags.lexicon)
|
|
62
|
+
analyzer = lexiconAnalyzerFrom(flags.lexicon);
|
|
63
|
+
if (flags.analyzer === 'llm') {
|
|
64
|
+
const llm = llmAnalyzerFromEnv();
|
|
65
|
+
if (llm)
|
|
66
|
+
analyzer = llm;
|
|
67
|
+
else
|
|
68
|
+
process.stderr.write('soulbridge: SOULBRIDGE_AFFECT_LLM_URL not set; using the lexicon analyzer\n');
|
|
69
|
+
}
|
|
70
|
+
return analyzer;
|
|
71
|
+
}
|
|
72
|
+
function open(flags) {
|
|
73
|
+
const config = ensureConfig();
|
|
74
|
+
const dbPath = resolveDbPath(flags.db);
|
|
75
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
55
76
|
const soul = new Soul({
|
|
56
|
-
store,
|
|
57
|
-
soulId:
|
|
58
|
-
userId:
|
|
59
|
-
name:
|
|
60
|
-
|
|
77
|
+
store: openSqlite(dbPath),
|
|
78
|
+
soulId: config.soulId,
|
|
79
|
+
userId: config.userId,
|
|
80
|
+
name: config.name,
|
|
81
|
+
deviceId: config.deviceId,
|
|
82
|
+
analyzer: pickAnalyzer(flags),
|
|
61
83
|
});
|
|
62
|
-
return { soul,
|
|
84
|
+
return { soul, dbPath, deviceId: config.deviceId };
|
|
63
85
|
}
|
|
64
86
|
function out(value) {
|
|
65
87
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -70,7 +92,6 @@ async function demo() {
|
|
|
70
92
|
const file = resolve(dir, 'demo.db');
|
|
71
93
|
for (const suffix of ['', '-wal', '-shm'])
|
|
72
94
|
rmSync(`${file}${suffix}`, { force: true });
|
|
73
|
-
// Backdate the birthday so the demo shows a relationship that has time in it.
|
|
74
95
|
const seed = openSqlite(file);
|
|
75
96
|
seed.ensureSoul({ soulId: 'xiaoling', name: '小灵', createdAt: Date.now() - 3 * 86_400_000 });
|
|
76
97
|
seed.close();
|
|
@@ -85,27 +106,55 @@ async function demo() {
|
|
|
85
106
|
{ role: 'user', content: '不对,这个方案是错的' },
|
|
86
107
|
]);
|
|
87
108
|
process.stdout.write(`写入 ${result.memories} 条记忆,情绪 ${JSON.stringify(result.emotion)}\n\n`);
|
|
88
|
-
process.stdout.write('===
|
|
89
|
-
|
|
90
|
-
process.stdout.write('
|
|
109
|
+
process.stdout.write('=== 用户说了一句带情绪的话(observe)===\n');
|
|
110
|
+
const observed = await soul.observe({ role: 'user', content: '我特别难过,这个方案又被拒了' });
|
|
111
|
+
process.stdout.write(`affect: ${observed.affect.dominant ?? 'none'} / 强度 ${observed.affect.intensity} / 置信 ${observed.affect.confidence}\n\n`);
|
|
112
|
+
process.stdout.write('=== 第 2 次会话:开场(含情绪段)===\n');
|
|
91
113
|
process.stdout.write(`${soul.context({ query: '提交前要做什么' }).text}\n\n`);
|
|
92
114
|
process.stdout.write('=== stats ===\n');
|
|
93
115
|
out(soul.stats());
|
|
94
116
|
process.stdout.write(`\n数据库:${file}\n`);
|
|
95
117
|
}
|
|
118
|
+
function exportJsonl(soul, deviceId) {
|
|
119
|
+
const lines = [
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
type: 'header',
|
|
122
|
+
version: 1,
|
|
123
|
+
soulId: soul.soulId,
|
|
124
|
+
userId: soul.userId,
|
|
125
|
+
deviceId,
|
|
126
|
+
exportedAt: Date.now(),
|
|
127
|
+
}),
|
|
128
|
+
];
|
|
129
|
+
const memories = soul.store.query({
|
|
130
|
+
soulId: soul.soulId,
|
|
131
|
+
userId: soul.userId,
|
|
132
|
+
status: ['active', 'superseded', 'archived'],
|
|
133
|
+
limit: 100_000,
|
|
134
|
+
});
|
|
135
|
+
for (const memory of memories)
|
|
136
|
+
lines.push(JSON.stringify({ type: 'memory', memory }));
|
|
137
|
+
const samples = soul.store.recentAffect(soul.soulId, soul.userId, 0);
|
|
138
|
+
for (const sample of samples) {
|
|
139
|
+
const withAt = sample;
|
|
140
|
+
lines.push(JSON.stringify({ type: 'affect', at: withAt.at ?? 0, signal: sample }));
|
|
141
|
+
}
|
|
142
|
+
return `${lines.join('\n')}\n`;
|
|
143
|
+
}
|
|
96
144
|
async function main() {
|
|
97
145
|
const { cmd, positional, flags } = parse(process.argv.slice(2));
|
|
98
146
|
switch (cmd) {
|
|
99
147
|
case 'init': {
|
|
100
|
-
const
|
|
101
|
-
|
|
148
|
+
const config = ensureConfig();
|
|
149
|
+
const { dbPath } = open(flags);
|
|
150
|
+
out({ ok: true, db: dbPath, config });
|
|
102
151
|
return;
|
|
103
152
|
}
|
|
104
153
|
case 'remember': {
|
|
105
154
|
const content = positional.join(' ').trim();
|
|
106
155
|
if (!content)
|
|
107
156
|
throw new Error('usage: soulbridge remember "内容" [--type T] [--importance N]');
|
|
108
|
-
const { soul } = open();
|
|
157
|
+
const { soul } = open(flags);
|
|
109
158
|
const id = soul.remember(content, {
|
|
110
159
|
type: flags.type ?? 'fact',
|
|
111
160
|
importance: flags.importance ? Number(flags.importance) : undefined,
|
|
@@ -114,7 +163,7 @@ async function main() {
|
|
|
114
163
|
return;
|
|
115
164
|
}
|
|
116
165
|
case 'context': {
|
|
117
|
-
const { soul } = open();
|
|
166
|
+
const { soul } = open(flags);
|
|
118
167
|
const result = soul.context({
|
|
119
168
|
query: flags.query,
|
|
120
169
|
maxChars: flags.max ? Number(flags.max) : undefined,
|
|
@@ -126,17 +175,30 @@ async function main() {
|
|
|
126
175
|
const query = positional.join(' ').trim();
|
|
127
176
|
if (!query)
|
|
128
177
|
throw new Error('usage: soulbridge recall "查询" [--limit N]');
|
|
129
|
-
const { soul } = open();
|
|
178
|
+
const { soul } = open(flags);
|
|
130
179
|
const hits = soul.recall(query, flags.limit ? Number(flags.limit) : 5);
|
|
131
|
-
out(hits.map((h) => ({
|
|
180
|
+
out(hits.map((h) => ({
|
|
181
|
+
id: h.memory.id,
|
|
182
|
+
type: h.memory.type,
|
|
183
|
+
content: h.memory.content,
|
|
184
|
+
score: Number(h.score.toFixed(3)),
|
|
185
|
+
})));
|
|
132
186
|
return;
|
|
133
187
|
}
|
|
134
188
|
case 'observe': {
|
|
135
189
|
const content = positional.join(' ').trim();
|
|
136
190
|
if (!content)
|
|
137
191
|
throw new Error('usage: soulbridge observe "一句话"');
|
|
138
|
-
const { soul } = open();
|
|
139
|
-
out(soul.observe({ role: 'user', content }));
|
|
192
|
+
const { soul } = open(flags);
|
|
193
|
+
out(await soul.observe({ role: 'user', content }));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case 'affect': {
|
|
197
|
+
const text = positional.join(' ').trim();
|
|
198
|
+
if (!text)
|
|
199
|
+
throw new Error('usage: soulbridge affect "文本" [--analyzer lexicon|llm] [--lexicon path]');
|
|
200
|
+
const analyzer = pickAnalyzer(flags);
|
|
201
|
+
out(analyzer ? await analyzer.analyze(text) : analyzeAffect(text));
|
|
140
202
|
return;
|
|
141
203
|
}
|
|
142
204
|
case 'reflect': {
|
|
@@ -154,13 +216,96 @@ async function main() {
|
|
|
154
216
|
const role = /^(assistant|助手)$/i.test(match[1]) ? 'assistant' : 'user';
|
|
155
217
|
return { role, content: match[2] };
|
|
156
218
|
});
|
|
157
|
-
const { soul } = open();
|
|
219
|
+
const { soul } = open(flags);
|
|
158
220
|
out(await soul.reflect(turns));
|
|
159
221
|
return;
|
|
160
222
|
}
|
|
223
|
+
case 'export': {
|
|
224
|
+
const file = flags.out;
|
|
225
|
+
if (!file)
|
|
226
|
+
throw new Error('usage: soulbridge export --out soulbridge.jsonl');
|
|
227
|
+
const { soul, deviceId } = open(flags);
|
|
228
|
+
const body = exportJsonl(soul, deviceId);
|
|
229
|
+
const target = resolve(file);
|
|
230
|
+
writeFileSync(target, body, 'utf8');
|
|
231
|
+
out({ ok: true, file: target, lines: body.trimEnd().split('\n').length });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
case 'import': {
|
|
235
|
+
// Steering file (AGENTS.md / CLAUDE.md) → memories (ADR 0022).
|
|
236
|
+
if (flags.from) {
|
|
237
|
+
const file = resolve(flags.from);
|
|
238
|
+
const drafts = parseSteering(readFileSync(file, 'utf8'));
|
|
239
|
+
const { soul } = open(flags);
|
|
240
|
+
const dryRun = flags['dry-run'] === 'true';
|
|
241
|
+
const existing = new Set(soul.store
|
|
242
|
+
.query({ soulId: soul.soulId, userId: soul.userId, limit: 100_000 })
|
|
243
|
+
.map((m) => normalizeForDedupe(m.content)));
|
|
244
|
+
let written = 0;
|
|
245
|
+
let skipped = 0;
|
|
246
|
+
for (const draft of drafts) {
|
|
247
|
+
const key = normalizeForDedupe(draft.text);
|
|
248
|
+
if (!key || existing.has(key)) {
|
|
249
|
+
skipped += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
existing.add(key);
|
|
253
|
+
if (!dryRun)
|
|
254
|
+
soul.remember(draft.text, { type: draft.type, importance: draft.importance });
|
|
255
|
+
written += 1;
|
|
256
|
+
}
|
|
257
|
+
out({ ok: true, source: file, kind: 'steering', parsed: drafts.length, written, skipped, dryRun });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const file = flags.in;
|
|
261
|
+
if (!file)
|
|
262
|
+
throw new Error('usage: soulbridge import --from <steering.md> | --in <export.jsonl>');
|
|
263
|
+
const { soul, deviceId } = open(flags);
|
|
264
|
+
let memories = 0;
|
|
265
|
+
let affects = 0;
|
|
266
|
+
for (const line of readFileSync(resolve(file), 'utf8').split(/\r?\n/)) {
|
|
267
|
+
if (!line.trim())
|
|
268
|
+
continue;
|
|
269
|
+
const record = JSON.parse(line);
|
|
270
|
+
if (record.type === 'memory' && record.memory) {
|
|
271
|
+
soul.store.put(record.memory);
|
|
272
|
+
memories += 1;
|
|
273
|
+
}
|
|
274
|
+
else if (record.type === 'affect' && record.signal) {
|
|
275
|
+
soul.store.saveAffect(soul.soulId, soul.userId, record.signal, Number(record.at ?? 0), deviceId);
|
|
276
|
+
affects += 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
out({ ok: true, kind: 'jsonl', memories, affects });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
case 'ingest': {
|
|
283
|
+
const file = flags.file;
|
|
284
|
+
if (!file)
|
|
285
|
+
throw new Error('usage: soulbridge ingest --file transcript.jsonl [--dry-run]');
|
|
286
|
+
const { turns, skipped } = parseTranscriptJsonl(readFileSync(resolve(file), 'utf8'));
|
|
287
|
+
const { soul } = open(flags);
|
|
288
|
+
const dryRun = flags['dry-run'] === 'true';
|
|
289
|
+
if (!dryRun) {
|
|
290
|
+
for (const turn of turns) {
|
|
291
|
+
if (turn.role !== 'user')
|
|
292
|
+
continue;
|
|
293
|
+
const signal = await soul.analyze(turn.content);
|
|
294
|
+
try {
|
|
295
|
+
soul.store.saveAffect(soul.soulId, soul.userId, signal, turn.at ?? Date.now(), soul.deviceId);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Evaluation data is best-effort; ingestion must still complete.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const reflected = dryRun ? null : await soul.reflect(turns);
|
|
303
|
+
out({ ok: true, file: resolve(file), turns: turns.length, skipped, reflected, dryRun });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
161
306
|
case 'stats': {
|
|
162
|
-
const { soul,
|
|
163
|
-
out({ db:
|
|
307
|
+
const { soul, dbPath } = open(flags);
|
|
308
|
+
out({ db: dbPath, ...soul.stats() });
|
|
164
309
|
return;
|
|
165
310
|
}
|
|
166
311
|
case 'demo':
|
|
@@ -171,16 +316,22 @@ async function main() {
|
|
|
171
316
|
'soulbridge — 给任何 agent 装上一个人格层',
|
|
172
317
|
'',
|
|
173
318
|
'commands:',
|
|
174
|
-
' init
|
|
319
|
+
' init 初始化配置与数据库',
|
|
175
320
|
' remember <text> [--type T] 显式记住一条',
|
|
176
321
|
' context [--query Q] [--max N] 生成注入文本',
|
|
322
|
+
' observe <text> 观察一轮(含情绪分析)',
|
|
323
|
+
' affect <text> [--analyzer llm] 只做情绪分析',
|
|
177
324
|
' recall <q> [--limit N] 召回',
|
|
178
|
-
' observe <text> 观察一轮',
|
|
179
325
|
' reflect --text|--file 会话反思',
|
|
326
|
+
' export --out <file> 导出 JSONL',
|
|
327
|
+
' import --in <file> 导入 JSONL',
|
|
328
|
+
' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
|
|
329
|
+
' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
|
|
180
330
|
' stats 看状态',
|
|
181
331
|
' demo 端到端演示(无需模型)',
|
|
182
332
|
'',
|
|
183
|
-
'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME',
|
|
333
|
+
'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME,',
|
|
334
|
+
' SOULBRIDGE_AFFECT_LLM_URL / _MODEL / _KEY / _TIMEOUT',
|
|
184
335
|
'',
|
|
185
336
|
].join('\n'));
|
|
186
337
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { emptyAffect } from '../../core/src/index.js';
|
|
2
|
+
const CATEGORIES = ['joy', 'anger', 'sadness', 'fear', 'disgust', 'surprise'];
|
|
3
|
+
const SYSTEM_PROMPT = [
|
|
4
|
+
'你是情感分类器。只输出 JSON,不要解释。',
|
|
5
|
+
'JSON 结构:{"dominant":"joy|anger|sadness|fear|disgust|surprise|null","intensity":0-9,"valence":-1..1,"arousal":0..1,"confidence":0..1}',
|
|
6
|
+
'规则:只根据用户这句话判断;没有明显情绪时 dominant 为 null、confidence <= 0.3;不要猜测对方的人格。',
|
|
7
|
+
].join('\n');
|
|
8
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
9
|
+
function parseJsonBlock(content) {
|
|
10
|
+
const trimmed = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
|
11
|
+
const start = trimmed.indexOf('{');
|
|
12
|
+
const end = trimmed.lastIndexOf('}');
|
|
13
|
+
if (start < 0 || end < start)
|
|
14
|
+
throw new Error('model did not return JSON');
|
|
15
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
16
|
+
}
|
|
17
|
+
export function createLlmAnalyzer(options) {
|
|
18
|
+
const timeoutMs = options.timeoutMs ?? 3000;
|
|
19
|
+
const maxConfidence = options.maxConfidence ?? 0.85;
|
|
20
|
+
const endpoint = `${options.url.replace(/\/$/, '')}/chat/completions`;
|
|
21
|
+
return {
|
|
22
|
+
id: 'llm',
|
|
23
|
+
async analyze(text) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(endpoint, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
model: options.model,
|
|
35
|
+
temperature: 0,
|
|
36
|
+
messages: [
|
|
37
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
38
|
+
{ role: 'user', content: text.slice(0, 2000) },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`llm analyze failed: HTTP ${response.status}`);
|
|
45
|
+
const payload = (await response.json());
|
|
46
|
+
const content = payload.choices?.[0]?.message?.content;
|
|
47
|
+
if (!content)
|
|
48
|
+
throw new Error('llm analyze returned empty content');
|
|
49
|
+
const parsed = parseJsonBlock(content);
|
|
50
|
+
const dominant = typeof parsed.dominant === 'string' && CATEGORIES.includes(parsed.dominant)
|
|
51
|
+
? parsed.dominant
|
|
52
|
+
: null;
|
|
53
|
+
const intensity = clamp(Number(parsed.intensity ?? 0), 0, 9);
|
|
54
|
+
const confidence = clamp(Number(parsed.confidence ?? 0), 0, maxConfidence);
|
|
55
|
+
const signal = emptyAffect('llm');
|
|
56
|
+
return {
|
|
57
|
+
...signal,
|
|
58
|
+
dominant,
|
|
59
|
+
intensity,
|
|
60
|
+
valence: clamp(Number(parsed.valence ?? 0), -1, 1),
|
|
61
|
+
arousal: clamp(Number(parsed.arousal ?? 0), 0, 1),
|
|
62
|
+
confidence: dominant ? confidence : Math.min(confidence, 0.3),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Build the analyzer from env vars, or null when not configured. */
|
|
72
|
+
export function llmAnalyzerFromEnv() {
|
|
73
|
+
const url = process.env.SOULBRIDGE_AFFECT_LLM_URL;
|
|
74
|
+
if (!url)
|
|
75
|
+
return null;
|
|
76
|
+
return createLlmAnalyzer({
|
|
77
|
+
url,
|
|
78
|
+
model: process.env.SOULBRIDGE_AFFECT_LLM_MODEL ?? 'qwen3:1.7b',
|
|
79
|
+
apiKey: process.env.SOULBRIDGE_AFFECT_LLM_KEY,
|
|
80
|
+
timeoutMs: Number(process.env.SOULBRIDGE_AFFECT_LLM_TIMEOUT ?? 3000),
|
|
81
|
+
});
|
|
82
|
+
}
|