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.
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.1 骨架 · 未发布 · 接口随时会变**
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,7 @@ 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 "我不太开心,这个方案又被拒了"
49
62
  npx soulbridge demo # 起一个 soul,跑完整 context→observe→reflect→context
50
63
  ```
51
64
 
@@ -81,10 +94,15 @@ SQLite(默认) / Postgres(可选)
81
94
  |---|---|
82
95
  | [thesis](docs/thesis.md) | 核心命题、三条可证伪预测、四臂实验、放弃条件 |
83
96
  | [RFC 0001](docs/rfcs/0001-foundations.md) | 定位、用户、非目标、成功指标、四个动词、接入阶梯 |
84
- | [ADR 索引](docs/rfcs/README.md) | 11 条已生效的架构决定(定位/语言/存储/依赖/协议/许可/接入/范围/评估/质量) |
97
+ | [RFC 0002](docs/rfcs/0002-affect.md) | 输入侧情感层:两套情绪的边界、数据流、已知局限 |
98
+ | [ADR 索引](docs/rfcs/README.md) | 21 条已生效的架构决定 |
85
99
  | [protocol.v0](docs/spec/protocol.v0.md) | 四个动词的完整契约:字段、错误码、降级、测试映射 |
100
+ | [affect.v0](docs/spec/affect.v0.md) | 词库格式、8 条规则、置信度公式、黄金集与消融 |
101
+ | [storage.v0](docs/spec/storage.v0.md) | 路径、身份、迁移、并发、导出格式 |
102
+ | [sync.v0](docs/spec/sync.v0.md) | 跨设备合并语义(v0.5 实现) |
86
103
  | [quality](docs/quality.md) | 质量模型、九条门禁、五条评审清单 |
87
104
  | [references](docs/references.md) | 外部资料的去留与过期判断 |
105
+ | [runbook](docs/runbook.md) | 发版流程、维护操作、已知待办 |
88
106
 
89
107
  **规则**:架构级改动必须先有 RFC 或 ADR;聊天里达成的不算决定。
90
108
 
@@ -98,6 +116,8 @@ SQLite(默认) / Postgres(可选)
98
116
  - 默认 SQLite,落在 `~/.soulbridge/soul.db`
99
117
  - 零遥测、零上报
100
118
  - 记忆可导出、可删除、可迁移
119
+ - 情绪采样只存**词条与计数**,不存原句;30 天自动清理
120
+ - 默认不联网;只有显式启用 LLM 分析器时,才会把**当前这一条** user 文本发给你配置的端点
101
121
 
102
122
  ## License
103
123
 
@@ -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 --importance 8
6
+ * soulbridge remember "以后提交前先跑 make check" --type strategy
7
7
  * soulbridge context --query "提交前要做什么"
8
- * soulbridge recall "测试"
9
- * soulbridge reflect --text "..." (or --file transcript.txt)
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 } = 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 dbFile() {
48
- const file = process.env.SOULBRIDGE_DB ?? resolve(process.cwd(), '.soulbridge', 'soul.db');
49
- mkdirSync(dirname(file), { recursive: true });
50
- return file;
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 open(file, createdAt) {
53
- const target = file ?? dbFile();
54
- const store = openSqlite(target);
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: process.env.SOULBRIDGE_SOUL ?? 'xiaoling',
58
- userId: process.env.SOULBRIDGE_USER ?? 'local',
59
- name: process.env.SOULBRIDGE_NAME ?? '小灵',
60
- createdAt,
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, file: target };
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('=== 第 2 次会话:开场(无查询,按重要度注入全部)===\n');
89
- process.stdout.write(`${soul.context().text}\n\n`);
90
- process.stdout.write('=== 同一会话中,用户问「提交前要做什么」(有查询,只注入相关的)===\n');
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 { soul, file } = open();
101
- out({ ok: true, db: file, ...soul.stats() });
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) => ({ id: h.memory.id, type: h.memory.type, content: h.memory.content, score: Number(h.score.toFixed(3)) })));
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,47 @@ 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
+ const file = flags.in;
236
+ if (!file)
237
+ throw new Error('usage: soulbridge import --in soulbridge.jsonl');
238
+ const { soul, deviceId } = open(flags);
239
+ let memories = 0;
240
+ let affects = 0;
241
+ for (const line of readFileSync(resolve(file), 'utf8').split(/\r?\n/)) {
242
+ if (!line.trim())
243
+ continue;
244
+ const record = JSON.parse(line);
245
+ if (record.type === 'memory' && record.memory) {
246
+ soul.store.put(record.memory);
247
+ memories += 1;
248
+ }
249
+ else if (record.type === 'affect' && record.signal) {
250
+ soul.store.saveAffect(soul.soulId, soul.userId, record.signal, Number(record.at ?? 0), deviceId);
251
+ affects += 1;
252
+ }
253
+ }
254
+ out({ ok: true, memories, affects });
255
+ return;
256
+ }
161
257
  case 'stats': {
162
- const { soul, file } = open();
163
- out({ db: file, ...soul.stats() });
258
+ const { soul, dbPath } = open(flags);
259
+ out({ db: dbPath, ...soul.stats() });
164
260
  return;
165
261
  }
166
262
  case 'demo':
@@ -171,16 +267,20 @@ async function main() {
171
267
  'soulbridge — 给任何 agent 装上一个人格层',
172
268
  '',
173
269
  'commands:',
174
- ' init 初始化数据库',
270
+ ' init 初始化配置与数据库',
175
271
  ' remember <text> [--type T] 显式记住一条',
176
272
  ' context [--query Q] [--max N] 生成注入文本',
273
+ ' observe <text> 观察一轮(含情绪分析)',
274
+ ' affect <text> [--analyzer llm] 只做情绪分析',
177
275
  ' recall <q> [--limit N] 召回',
178
- ' observe <text> 观察一轮',
179
276
  ' reflect --text|--file 会话反思',
277
+ ' export --out <file> 导出 JSONL',
278
+ ' import --in <file> 导入 JSONL',
180
279
  ' stats 看状态',
181
280
  ' demo 端到端演示(无需模型)',
182
281
  '',
183
- 'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME',
282
+ 'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME,',
283
+ ' SOULBRIDGE_AFFECT_LLM_URL / _MODEL / _KEY / _TIMEOUT',
184
284
  '',
185
285
  ].join('\n'));
186
286
  }
@@ -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
+ }
@@ -0,0 +1,210 @@
1
+ import { EMPTY_AFFECT } from '../types.js';
2
+ import { ABSTAIN_TERMS, DEGREE_DELTA, NEGATORS } from './lexicon.js';
3
+ import { SEED_LEXICON } from './seed-lexicon.js';
4
+ export const MAX_INPUT_CHARS = 32_000;
5
+ export const NEGATION_WINDOW = 3;
6
+ export const DEGREE_WINDOW = 2;
7
+ export class AffectBudgetError extends Error {
8
+ code = 'BUDGET_EXCEEDED';
9
+ constructor(length) {
10
+ super(`affect input too large: ${length} chars (max ${MAX_INPUT_CHARS})`);
11
+ this.name = 'AffectBudgetError';
12
+ }
13
+ }
14
+ const cache = new WeakMap();
15
+ function prepare(lexicon) {
16
+ const cached = cache.get(lexicon);
17
+ if (cached)
18
+ return cached;
19
+ const entries = new Map(Object.entries(lexicon.entries));
20
+ const fillers = new Set(lexicon.fillers);
21
+ const hedges = new Set(lexicon.hedges);
22
+ const vague = new Set(Object.keys(lexicon.vague));
23
+ const degree = new Map();
24
+ for (const level of Object.keys(DEGREE_DELTA)) {
25
+ for (const word of lexicon.degree[level])
26
+ degree.set(word, DEGREE_DELTA[level]);
27
+ }
28
+ const dict = new Set([
29
+ ...entries.keys(),
30
+ ...fillers,
31
+ ...hedges,
32
+ ...vague,
33
+ ...degree.keys(),
34
+ ...NEGATORS,
35
+ ...ABSTAIN_TERMS,
36
+ ]);
37
+ let maxLen = 1;
38
+ for (const word of dict)
39
+ maxLen = Math.max(maxLen, word.length);
40
+ const prepared = { entries, fillers, hedges, vague, degree, dict, maxLen };
41
+ cache.set(lexicon, prepared);
42
+ return prepared;
43
+ }
44
+ /** Full-width punctuation → ASCII, strip code fences and shell lines, drop emoji. */
45
+ export function normalizeForAffect(text) {
46
+ const withoutCode = text.replace(/```[\s\S]*?```/g, ' ');
47
+ const lines = withoutCode
48
+ .split(/\r?\n/)
49
+ .filter((line) => !/^\s*\$\s/.test(line))
50
+ .join('\n');
51
+ return lines
52
+ .replace(/[\uff01-\uff5e]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
53
+ .replace(/[\u{1f000}-\u{1faff}\u{2600}-\u{27bf}\u{fe0f}\u{200d}]/gu, ' ')
54
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
55
+ .toLowerCase();
56
+ }
57
+ /** Max forward match over the lexicon dictionary; punctuation and spaces are separators. */
58
+ export function tokenizeForAffect(text, dict, maxLen) {
59
+ const tokens = [];
60
+ let i = 0;
61
+ while (i < text.length) {
62
+ const ch = text[i];
63
+ if (/[\s\p{P}\p{S}]/u.test(ch)) {
64
+ i += 1;
65
+ continue;
66
+ }
67
+ let matched = '';
68
+ for (let len = Math.min(maxLen, text.length - i); len >= 1; len -= 1) {
69
+ const candidate = text.slice(i, i + len);
70
+ if (dict.has(candidate)) {
71
+ matched = candidate;
72
+ break;
73
+ }
74
+ }
75
+ if (matched) {
76
+ tokens.push(matched);
77
+ i += matched.length;
78
+ }
79
+ else {
80
+ tokens.push(ch);
81
+ i += 1;
82
+ }
83
+ }
84
+ return tokens;
85
+ }
86
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
87
+ /**
88
+ * Negating a positive emotion turns it negative ("不开心"), but negating a
89
+ * negative one does not turn it positive ("不生气" is relief, not joy) — it
90
+ * becomes neutral. This is a deliberate, testable simplification.
91
+ */
92
+ function flip(polarity) {
93
+ if (polarity === 'positive')
94
+ return 'negative';
95
+ return 'neutral';
96
+ }
97
+ export function analyzeAffect(text, options = {}) {
98
+ if (typeof text !== 'string')
99
+ throw new TypeError('text must be a string');
100
+ if (text.length > MAX_INPUT_CHARS)
101
+ throw new AffectBudgetError(text.length);
102
+ const lexicon = options.lexicon ?? SEED_LEXICON;
103
+ const prepared = prepare(lexicon);
104
+ const normalized = normalizeForAffect(text);
105
+ const tokens = tokenizeForAffect(normalized, prepared.dict, prepared.maxLen);
106
+ const hits = [];
107
+ for (let i = 0; i < tokens.length; i += 1) {
108
+ const entry = prepared.entries.get(tokens[i]);
109
+ if (!entry)
110
+ continue;
111
+ let negated = false;
112
+ for (let back = 1; back <= NEGATION_WINDOW && i - back >= 0; back += 1) {
113
+ const prev = tokens[i - back];
114
+ if (NEGATORS.includes(prev)) {
115
+ negated = true;
116
+ break;
117
+ }
118
+ if (prepared.entries.has(prev))
119
+ break;
120
+ }
121
+ let degree = 0;
122
+ for (let back = 1; back <= DEGREE_WINDOW && i - back >= 0; back += 1) {
123
+ const delta = prepared.degree.get(tokens[i - back]);
124
+ if (delta !== undefined) {
125
+ degree = delta;
126
+ break;
127
+ }
128
+ }
129
+ let intensity = clamp(entry.intensity + degree, 1, 9);
130
+ let polarity = entry.polarity;
131
+ if (negated) {
132
+ polarity = flip(polarity);
133
+ intensity = Math.max(1, Math.round(intensity * 0.8));
134
+ }
135
+ hits.push({
136
+ term: tokens[i],
137
+ category: entry.category,
138
+ intensity,
139
+ polarity,
140
+ negated,
141
+ position: i,
142
+ });
143
+ }
144
+ const byCategory = new Map();
145
+ let weighted = 0;
146
+ let total = 0;
147
+ for (const hit of hits) {
148
+ byCategory.set(hit.category, (byCategory.get(hit.category) ?? 0) + hit.intensity);
149
+ const sign = hit.polarity === 'positive' ? 1 : hit.polarity === 'negative' ? -1 : 0;
150
+ weighted += sign * hit.intensity;
151
+ total += hit.intensity;
152
+ }
153
+ let dominant = null;
154
+ let best = 0;
155
+ for (const [category, sum] of byCategory) {
156
+ if (sum > best) {
157
+ best = sum;
158
+ dominant = category;
159
+ }
160
+ }
161
+ const emphasis = (normalized.match(/[!?]{2,}|…{2,}/g) ?? []).length + (normalized.match(/(.)\1{2,}/gu) ?? []).length;
162
+ let intensity = hits.length > 0 ? Math.max(...hits.map((h) => h.intensity)) : 0;
163
+ if (hits.length > 0 && emphasis > 0)
164
+ intensity = clamp(intensity + Math.min(2, emphasis), 1, 9);
165
+ const fillers = tokens.filter((t) => prepared.fillers.has(t)).length;
166
+ const hedges = tokens.filter((t) => prepared.hedges.has(t)).length;
167
+ const vague = tokens.filter((t) => prepared.vague.has(t)).length;
168
+ const abstain = hits.length === 0 && ABSTAIN_TERMS.some((term) => normalized.includes(term));
169
+ // No hits means no judgement at all — length alone must never produce confidence.
170
+ let confidence = 0;
171
+ if (hits.length > 0) {
172
+ confidence =
173
+ 0.4 * Math.min(1, hits.length / 2) +
174
+ 0.2 * Math.min(1, tokens.length / 20) +
175
+ 0.25 * (dominant ? 1 : 0) +
176
+ 0.15 * (intensity / 9);
177
+ if (tokens.length <= 3)
178
+ confidence *= 0.5;
179
+ }
180
+ if (abstain)
181
+ confidence = Math.min(confidence, 0.5);
182
+ return {
183
+ analyzer: 'lexicon',
184
+ hits,
185
+ dominant,
186
+ intensity: Math.round(intensity * 10) / 10,
187
+ valence: total > 0 ? Math.round((weighted / total) * 100) / 100 : 0,
188
+ arousal: clamp(intensity / 9 + 0.1 * emphasis, 0, 1),
189
+ features: {
190
+ fillers,
191
+ hedges,
192
+ vague,
193
+ tokens: tokens.length,
194
+ density: tokens.length > 0 ? (tokens.length - fillers - hedges) / tokens.length : 1,
195
+ },
196
+ confidence: clamp(Math.round(confidence * 100) / 100, 0, 1),
197
+ abstain,
198
+ };
199
+ }
200
+ /** Lexicon analyzer as a port implementation (ADR 0015). */
201
+ export const lexiconAnalyzer = {
202
+ id: 'lexicon',
203
+ analyze(text) {
204
+ return analyzeAffect(text);
205
+ },
206
+ };
207
+ /** Empty signal helper so callers do not reach for the shared constant by accident. */
208
+ export function emptyAffect(analyzer = 'none') {
209
+ return { ...EMPTY_AFFECT, analyzer, features: { ...EMPTY_AFFECT.features } };
210
+ }
@@ -0,0 +1,29 @@
1
+ export const DIRECTIVE_CATALOG = [
2
+ { id: 'negative_high', text: '对方情绪偏负面:先承接,再给方案;不要急着讲道理' },
3
+ { id: 'positive_high', text: '对方情绪偏正面:回答可以更简短' },
4
+ { id: 'hedging', text: '对方表达里犹豫词偏多:给结论,少加限定语' },
5
+ { id: 'fragmented', text: '对方表达较碎片:先复述你的理解,再回答' },
6
+ { id: 'vague_terms', text: '对方用词较笼统:必要时确认具体所指' },
7
+ ];
8
+ const per100 = (count, tokens) => (tokens > 0 ? (count * 100) / tokens : 0);
9
+ /** Which catalog entries the signal justifies, in priority order, capped at `max`. */
10
+ export function selectDirectives(signal, max = 2) {
11
+ if (signal.hits.length === 0 || signal.confidence < 0.6)
12
+ return [];
13
+ const triggered = new Set();
14
+ if (signal.valence <= -0.4 && signal.intensity >= 6)
15
+ triggered.add('negative_high');
16
+ if (signal.valence >= 0.4 && signal.intensity >= 6)
17
+ triggered.add('positive_high');
18
+ // Absolute minimums stop a single "这个" in a ten-token sentence from
19
+ // reading as "fragmented" — rate alone is misleading on short inputs.
20
+ if (signal.features.hedges >= 2 && per100(signal.features.hedges, signal.features.tokens) >= 6) {
21
+ triggered.add('hedging');
22
+ }
23
+ if (signal.features.fillers >= 3 && per100(signal.features.fillers, signal.features.tokens) >= 8) {
24
+ triggered.add('fragmented');
25
+ }
26
+ if (signal.features.vague >= 3)
27
+ triggered.add('vague_terms');
28
+ return DIRECTIVE_CATALOG.filter((d) => triggered.has(d.id)).slice(0, max);
29
+ }
@@ -0,0 +1,4 @@
1
+ export { analyzeAffect, lexiconAnalyzer, emptyAffect, AffectBudgetError, normalizeForAffect, tokenizeForAffect, MAX_INPUT_CHARS, } from './analyze.js';
2
+ export { SEED_LEXICON } from './seed-lexicon.js';
3
+ export { validateLexicon, DEGREE_DELTA, NEGATORS, ABSTAIN_TERMS } from './lexicon.js';
4
+ export { DIRECTIVE_CATALOG, selectDirectives } from './directives.js';