soulbridge 0.0.4 → 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
CHANGED
|
@@ -59,6 +59,7 @@ npx soulbridge init
|
|
|
59
59
|
npx soulbridge remember --type strategy "提交前先跑 make check,能省一轮 CI"
|
|
60
60
|
npx soulbridge context --query "要不要先跑测试"
|
|
61
61
|
npx soulbridge affect "我不太开心,这个方案又被拒了"
|
|
62
|
+
npx soulbridge import --from AGENTS.md # 把已有的项目约定导进来
|
|
62
63
|
npx soulbridge demo # 起一个 soul,跑完整 context→observe→reflect→context
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -88,6 +89,15 @@ SQLite(默认) / Postgres(可选)
|
|
|
88
89
|
|
|
89
90
|
详见 [docs/architecture.md](docs/architecture.md)。
|
|
90
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
|
+
|
|
91
101
|
## 设计文档
|
|
92
102
|
|
|
93
103
|
| 文档 | 内容 |
|
|
@@ -95,11 +105,12 @@ SQLite(默认) / Postgres(可选)
|
|
|
95
105
|
| [thesis](docs/thesis.md) | 核心命题、三条可证伪预测、四臂实验、放弃条件 |
|
|
96
106
|
| [RFC 0001](docs/rfcs/0001-foundations.md) | 定位、用户、非目标、成功指标、四个动词、接入阶梯 |
|
|
97
107
|
| [RFC 0002](docs/rfcs/0002-affect.md) | 输入侧情感层:两套情绪的边界、数据流、已知局限 |
|
|
98
|
-
| [ADR 索引](docs/rfcs/README.md) |
|
|
108
|
+
| [ADR 索引](docs/rfcs/README.md) | 23 条已生效的架构决定 |
|
|
99
109
|
| [protocol.v0](docs/spec/protocol.v0.md) | 四个动词的完整契约:字段、错误码、降级、测试映射 |
|
|
100
110
|
| [affect.v0](docs/spec/affect.v0.md) | 词库格式、8 条规则、置信度公式、黄金集与消融 |
|
|
101
111
|
| [storage.v0](docs/spec/storage.v0.md) | 路径、身份、迁移、并发、导出格式 |
|
|
102
112
|
| [sync.v0](docs/spec/sync.v0.md) | 跨设备合并语义(v0.5 实现) |
|
|
113
|
+
| [transcript.v1](docs/spec/transcript.v1.md) | 可移植会话转写格式(ingest 的输入) |
|
|
103
114
|
| [quality](docs/quality.md) | 质量模型、九条门禁、五条评审清单 |
|
|
104
115
|
| [references](docs/references.md) | 外部资料的去留与过期判断 |
|
|
105
116
|
| [runbook](docs/runbook.md) | 发版流程、维护操作、已知待办 |
|
|
@@ -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`
|
|
@@ -24,7 +24,7 @@ process.on('warning', (warning) => {
|
|
|
24
24
|
return;
|
|
25
25
|
process.stderr.write(`${warning.name}: ${warning.message}\n`);
|
|
26
26
|
});
|
|
27
|
-
const { openSqlite, Soul, ensureConfig, resolveDbPath, analyzeAffect, validateLexicon } = await import('../../core/src/index.js');
|
|
27
|
+
const { openSqlite, Soul, ensureConfig, resolveDbPath, analyzeAffect, validateLexicon, parseSteering, normalizeForDedupe, parseTranscriptJsonl, } = await import('../../core/src/index.js');
|
|
28
28
|
const { llmAnalyzerFromEnv } = await import('./llm-analyzer.js');
|
|
29
29
|
function parse(argv) {
|
|
30
30
|
const [cmd = 'help', ...rest] = argv;
|
|
@@ -232,9 +232,34 @@ async function main() {
|
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
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
|
+
}
|
|
235
260
|
const file = flags.in;
|
|
236
261
|
if (!file)
|
|
237
|
-
throw new Error('usage: soulbridge import --in
|
|
262
|
+
throw new Error('usage: soulbridge import --from <steering.md> | --in <export.jsonl>');
|
|
238
263
|
const { soul, deviceId } = open(flags);
|
|
239
264
|
let memories = 0;
|
|
240
265
|
let affects = 0;
|
|
@@ -251,7 +276,31 @@ async function main() {
|
|
|
251
276
|
affects += 1;
|
|
252
277
|
}
|
|
253
278
|
}
|
|
254
|
-
out({ ok: true, memories, affects });
|
|
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 });
|
|
255
304
|
return;
|
|
256
305
|
}
|
|
257
306
|
case 'stats': {
|
|
@@ -276,6 +325,8 @@ async function main() {
|
|
|
276
325
|
' reflect --text|--file 会话反思',
|
|
277
326
|
' export --out <file> 导出 JSONL',
|
|
278
327
|
' import --in <file> 导入 JSONL',
|
|
328
|
+
' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
|
|
329
|
+
' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
|
|
279
330
|
' stats 看状态',
|
|
280
331
|
' demo 端到端演示(无需模型)',
|
|
281
332
|
'',
|
|
@@ -13,6 +13,8 @@ export * from './affect/index.js';
|
|
|
13
13
|
export { measureStyle } from './style/measure.js';
|
|
14
14
|
export * from './storage/config.js';
|
|
15
15
|
export * from './storage/merge.js';
|
|
16
|
+
export * from './storage/steering.js';
|
|
17
|
+
export * from './storage/transcript.js';
|
|
16
18
|
import { buildContext } from './context.js';
|
|
17
19
|
import { reflect, ruleExtract, nextEmotion } from './reflect.js';
|
|
18
20
|
import { rank, tokenize } from './scoring.js';
|
|
@@ -55,14 +57,9 @@ export class Soul {
|
|
|
55
57
|
this.store.touch(ranked.map((r) => r.memory.id));
|
|
56
58
|
return ranked;
|
|
57
59
|
}
|
|
58
|
-
/** Pure analysis, no storage
|
|
59
|
-
analyze(text) {
|
|
60
|
-
|
|
61
|
-
return this.analyzer.analyze(text);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return analyzeAffect(text);
|
|
65
|
-
}
|
|
60
|
+
/** Pure analysis, no storage. Async because the analyzer may call a model. */
|
|
61
|
+
async analyze(text) {
|
|
62
|
+
return this.analyzeTurn(text);
|
|
66
63
|
}
|
|
67
64
|
/**
|
|
68
65
|
* Verb 2: after each turn.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const STEERING_MAX_DRAFTS = 200;
|
|
2
|
+
const MIN_LENGTH = 8;
|
|
3
|
+
const MAX_LENGTH = 240;
|
|
4
|
+
/** Lowercase, strip punctuation and whitespace — for duplicate detection only. */
|
|
5
|
+
export function normalizeForDedupe(text) {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[\s\p{P}\p{S}]/gu, '')
|
|
9
|
+
.slice(0, 400);
|
|
10
|
+
}
|
|
11
|
+
function classify(text) {
|
|
12
|
+
// Preference first: "我喜欢 X,不要 Y" is a trait, not a rule.
|
|
13
|
+
if (/(我喜欢|偏好|习惯|倾向|prefer|i like)/i.test(text)) {
|
|
14
|
+
return { type: 'trait', importance: 6 };
|
|
15
|
+
}
|
|
16
|
+
if (/(必须|禁止|不要|务必|一律|每次|never|must|always|shall)/i.test(text)) {
|
|
17
|
+
return { type: 'strategy', importance: 8 };
|
|
18
|
+
}
|
|
19
|
+
if (/(项目|仓库|目录|技术栈|使用|基于|project|repo|stack)/i.test(text)) {
|
|
20
|
+
return { type: 'fact', importance: 6 };
|
|
21
|
+
}
|
|
22
|
+
return { type: 'fact', importance: 5 };
|
|
23
|
+
}
|
|
24
|
+
function stripFences(text) {
|
|
25
|
+
return text.replace(/```[\s\S]*?```/g, '');
|
|
26
|
+
}
|
|
27
|
+
function clean(line) {
|
|
28
|
+
return line
|
|
29
|
+
.replace(/^[-*+]\s+/, '')
|
|
30
|
+
.replace(/^\d+[.)]\s+/, '')
|
|
31
|
+
.replace(/^\[[ xX]\]\s+/, '')
|
|
32
|
+
.replace(/\*\*|__|`/g, '')
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
function isSkippable(line) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return true;
|
|
39
|
+
if (/^#{1,6}\s/.test(trimmed))
|
|
40
|
+
return true;
|
|
41
|
+
if (/^\|/.test(trimmed))
|
|
42
|
+
return true;
|
|
43
|
+
if (/^!\[/.test(trimmed))
|
|
44
|
+
return true;
|
|
45
|
+
if (/^<[^>]+>$/.test(trimmed))
|
|
46
|
+
return true;
|
|
47
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
48
|
+
return true;
|
|
49
|
+
if (/^[-*_]{3,}$/.test(trimmed))
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Extract memory candidates from a steering file.
|
|
55
|
+
* Headings are dropped on purpose: they carry structure, not statements.
|
|
56
|
+
*/
|
|
57
|
+
export function parseSteering(text) {
|
|
58
|
+
const body = stripFences(text);
|
|
59
|
+
const drafts = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
body.split(/\r?\n/).forEach((raw, index) => {
|
|
62
|
+
if (isSkippable(raw))
|
|
63
|
+
return;
|
|
64
|
+
const bullet = /^\s*(?:[-*+]|\d+[.)]|\[[ xX]\])\s+/.test(raw);
|
|
65
|
+
const content = clean(raw);
|
|
66
|
+
// Bullets are intentional statements; free paragraphs need more substance.
|
|
67
|
+
const minLength = bullet ? MIN_LENGTH : 12;
|
|
68
|
+
if (content.length < minLength || content.length > MAX_LENGTH)
|
|
69
|
+
return;
|
|
70
|
+
// Paragraphs are noisier than bullets; require a statement-like shape.
|
|
71
|
+
if (!bullet && !/[。.!!??:]$/.test(content))
|
|
72
|
+
return;
|
|
73
|
+
if (!bullet && !/[\u4e00-\u9fa5]/.test(content) && content.split(/\s+/).length < 4)
|
|
74
|
+
return;
|
|
75
|
+
const key = normalizeForDedupe(content);
|
|
76
|
+
if (!key || seen.has(key))
|
|
77
|
+
return;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
const { type, importance } = classify(content);
|
|
80
|
+
drafts.push({ text: content, type, importance, line: index + 1 });
|
|
81
|
+
});
|
|
82
|
+
return drafts.slice(0, STEERING_MAX_DRAFTS);
|
|
83
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const MAX_TURNS = 5000;
|
|
2
|
+
const MAX_CONTENT = 20_000;
|
|
3
|
+
function textOf(content) {
|
|
4
|
+
if (typeof content === 'string')
|
|
5
|
+
return content;
|
|
6
|
+
if (Array.isArray(content)) {
|
|
7
|
+
return content
|
|
8
|
+
.map((part) => {
|
|
9
|
+
if (typeof part === 'string')
|
|
10
|
+
return part;
|
|
11
|
+
if (part && typeof part === 'object') {
|
|
12
|
+
const p = part;
|
|
13
|
+
if (typeof p.text === 'string')
|
|
14
|
+
return p.text;
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
})
|
|
18
|
+
.join('\n');
|
|
19
|
+
}
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
function roleOf(record) {
|
|
23
|
+
const nested = record.message && typeof record.message === 'object'
|
|
24
|
+
? record.message
|
|
25
|
+
: null;
|
|
26
|
+
const role = record.role ?? nested?.role ?? record.type;
|
|
27
|
+
if (role === 'user' || role === 'human')
|
|
28
|
+
return 'user';
|
|
29
|
+
if (role === 'assistant' || role === 'ai' || role === 'model')
|
|
30
|
+
return 'assistant';
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export function parseTranscriptJsonl(text) {
|
|
34
|
+
const turns = [];
|
|
35
|
+
let skipped = 0;
|
|
36
|
+
for (const line of text.split(/\r?\n/)) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
continue;
|
|
40
|
+
let record;
|
|
41
|
+
try {
|
|
42
|
+
record = JSON.parse(trimmed);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
skipped += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const role = roleOf(record);
|
|
49
|
+
if (!role) {
|
|
50
|
+
skipped += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const nested = record.message && typeof record.message === 'object'
|
|
54
|
+
? record.message
|
|
55
|
+
: null;
|
|
56
|
+
const content = textOf(record.content ?? record.text ?? nested?.content).trim();
|
|
57
|
+
if (!content) {
|
|
58
|
+
skipped += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const at = typeof record.at === 'number' ? record.at : undefined;
|
|
62
|
+
const last = turns.at(-1);
|
|
63
|
+
if (last && last.role === role && last.content === content)
|
|
64
|
+
continue;
|
|
65
|
+
turns.push({ role, content: content.slice(0, MAX_CONTENT), ...(at === undefined ? {} : { at }) });
|
|
66
|
+
if (turns.length >= MAX_TURNS)
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return { turns, skipped };
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "soulbridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "A personality layer for any agent: continuity, forgetting, and growth across tools.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"adapters",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE",
|
|
14
15
|
"NOTICE"
|