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.
@@ -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
- function toMemory(row) {
48
- return {
49
- id: String(row.id),
50
- soulId: String(row.soul_id),
51
- userId: String(row.user_id),
52
- type: row.type,
53
- content: String(row.content),
54
- importance: Number(row.importance),
55
- vividness: Number(row.vividness),
56
- recallCount: Number(row.recall_count),
57
- status: row.status,
58
- visibility: row.visibility,
59
- keywords: JSON.parse(String(row.keywords ?? '[]')),
60
- createdAt: Number(row.created_at),
61
- lastRecalledAt: row.last_recalled_at == null ? undefined : Number(row.last_recalled_at),
62
- validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
63
- validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
64
- supersededBy: row.superseded_by == null ? undefined : String(row.superseded_by),
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
- db.prepare('INSERT OR REPLACE INTO schema_meta (k, v) VALUES (?, ?)').run('version', '1');
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, superseded_by
76
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
- db.prepare('INSERT INTO souls (soul_id, name, creator, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(soul_id) DO NOTHING').run(identity.soulId, identity.name, identity.creator ?? null, identity.createdAt);
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
- db.prepare(`INSERT INTO emotions (soul_id, user_id, passion, bond, tension, self_regard, curiosity, contentment)
116
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
117
- ON CONFLICT(soul_id, user_id) DO UPDATE SET
118
- passion = excluded.passion, bond = excluded.bond, tension = excluded.tension,
119
- self_regard = excluded.self_regard, curiosity = excluded.curiosity,
120
- contentment = excluded.contentment`).run(soulId, userId, emotion.passion, emotion.bond, emotion.tension, emotion.selfRegard, emotion.curiosity, emotion.contentment);
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') AND status IN (${placeholders})
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(toMemory);
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
- for (const id of ids)
139
- stmt.run(now, id);
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("SELECT COUNT(*) AS n FROM memories WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')")
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
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Identity and location (ADR 0020).
3
+ *
4
+ * Cross-tool only works if every host resolves the same person: the database
5
+ * lives in the user's home (not the cwd), and the soul/user/device identity
6
+ * lives in a config file that all hosts read.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { randomBytes } from 'node:crypto';
12
+ export const DEFAULT_SOUL_ID = 'xiaoling';
13
+ export const DEFAULT_USER_ID = 'local';
14
+ export const DEFAULT_NAME = '小灵';
15
+ export function soulbridgeHome() {
16
+ return process.env.SOULBRIDGE_HOME ?? join(homedir(), '.soulbridge');
17
+ }
18
+ export function configPath() {
19
+ return join(soulbridgeHome(), 'config.json');
20
+ }
21
+ export function defaultDbPath() {
22
+ return join(soulbridgeHome(), 'soul.db');
23
+ }
24
+ export function newDeviceId() {
25
+ return `dev_${randomBytes(4).toString('hex')}`;
26
+ }
27
+ /** DB location: explicit flag > env > home. Never the cwd. */
28
+ export function resolveDbPath(explicit) {
29
+ return explicit ?? process.env.SOULBRIDGE_DB ?? defaultDbPath();
30
+ }
31
+ export function loadConfig() {
32
+ const file = configPath();
33
+ if (!existsSync(file))
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
37
+ if (!parsed.soulId || !parsed.userId)
38
+ return null;
39
+ return {
40
+ soulId: parsed.soulId,
41
+ userId: parsed.userId,
42
+ name: parsed.name ?? DEFAULT_NAME,
43
+ deviceId: parsed.deviceId ?? newDeviceId(),
44
+ };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export function saveConfig(config) {
51
+ const home = soulbridgeHome();
52
+ if (!existsSync(home))
53
+ mkdirSync(home, { recursive: true });
54
+ writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
55
+ }
56
+ /**
57
+ * Read the config, creating or completing it when needed.
58
+ * Env overrides win, but are not persisted (an override is per-process).
59
+ */
60
+ export function ensureConfig(overrides = {}) {
61
+ const loaded = loadConfig();
62
+ const env = {};
63
+ if (process.env.SOULBRIDGE_SOUL)
64
+ env.soulId = process.env.SOULBRIDGE_SOUL;
65
+ if (process.env.SOULBRIDGE_USER)
66
+ env.userId = process.env.SOULBRIDGE_USER;
67
+ if (process.env.SOULBRIDGE_NAME)
68
+ env.name = process.env.SOULBRIDGE_NAME;
69
+ const merged = {
70
+ soulId: overrides.soulId ?? env.soulId ?? loaded?.soulId ?? DEFAULT_SOUL_ID,
71
+ userId: overrides.userId ?? env.userId ?? loaded?.userId ?? DEFAULT_USER_ID,
72
+ name: overrides.name ?? env.name ?? loaded?.name ?? DEFAULT_NAME,
73
+ deviceId: overrides.deviceId ?? loaded?.deviceId ?? newDeviceId(),
74
+ };
75
+ const changed = !loaded ||
76
+ loaded.soulId !== merged.soulId ||
77
+ loaded.userId !== merged.userId ||
78
+ loaded.name !== merged.name ||
79
+ loaded.deviceId !== merged.deviceId;
80
+ if (changed)
81
+ saveConfig(merged);
82
+ return merged;
83
+ }
@@ -0,0 +1,21 @@
1
+ export function pickLatest(a, b) {
2
+ if (a.updatedAt !== b.updatedAt)
3
+ return a.updatedAt > b.updatedAt ? a : b;
4
+ return a.device >= b.device ? a : b;
5
+ }
6
+ /** Union by id, keeping tombstones (deletion must survive a merge). */
7
+ export function mergeRecords(records) {
8
+ const byId = new Map();
9
+ for (const record of records) {
10
+ const existing = byId.get(record.id);
11
+ byId.set(record.id, existing ? pickLatest(existing, record) : record);
12
+ }
13
+ return [...byId.values()];
14
+ }
15
+ /** A record is visible only if the winning version is not a tombstone. */
16
+ export function isVisible(record) {
17
+ return record.deleted !== true;
18
+ }
19
+ export function liveRecords(records) {
20
+ return mergeRecords(records).filter(isVisible);
21
+ }
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Output-side style measurement.
3
+ *
4
+ * This is the instrument for the emotion→behaviour experiment (ADR 0017): we
5
+ * measure what the agent actually wrote, never what it says about itself.
6
+ */
7
+ import { SEED_LEXICON } from '../affect/seed-lexicon.js';
8
+ import { normalizeForAffect, tokenizeForAffect } from '../affect/analyze.js';
9
+ const CONFIRM_OPENERS = ['好的', '好', '我先', '让我先', '我理解', '先确认', '确认一下', '明白'];
10
+ export function measureStyle(text, lexicon = SEED_LEXICON) {
11
+ if (typeof text !== 'string')
12
+ throw new TypeError('text must be a string');
13
+ const normalized = normalizeForAffect(text);
14
+ let hedges = 0;
15
+ if (lexicon.hedges.length > 0) {
16
+ const dict = new Set(lexicon.hedges);
17
+ const maxLen = lexicon.hedges.reduce((n, w) => Math.max(n, w.length), 1);
18
+ hedges = tokenizeForAffect(normalized, dict, maxLen).filter((t) => dict.has(t)).length;
19
+ }
20
+ const questions = (normalized.match(/\?/g) ?? []).length;
21
+ const head = normalized.trim().slice(0, 24);
22
+ return {
23
+ length: text.length,
24
+ hedgeRate: normalized.length > 0 ? Math.round(((hedges * 100) / normalized.length) * 100) / 100 : 0,
25
+ questions,
26
+ confirmFirst: CONFIRM_OPENERS.some((opener) => head.startsWith(opener)),
27
+ };
28
+ }
@@ -12,3 +12,16 @@ export const DEFAULT_EMOTION = {
12
12
  curiosity: 0.3,
13
13
  contentment: 0.3,
14
14
  };
15
+ export const EMPTY_AFFECT = {
16
+ analyzer: 'none',
17
+ hits: [],
18
+ dominant: null,
19
+ intensity: 0,
20
+ valence: 0,
21
+ arousal: 0,
22
+ features: { fillers: 0, hedges: 0, vague: 0, tokens: 0, density: 1 },
23
+ confidence: 0,
24
+ abstain: false,
25
+ };
26
+ /** Wire version of the four verbs. Additive changes bump MINOR (ADR 0005). */
27
+ export const PROTOCOL_VERSION = '0.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.3",
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"