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
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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')
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
+
"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"
|