soulbridge 0.0.1 → 0.0.2

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,117 +0,0 @@
1
- /**
2
- * Reflection: turn a finished session into durable memory + an emotion update.
3
- *
4
- * `core` never calls a model itself. If the caller passes an `LlmFn`, richer
5
- * extraction is possible later; today the rule path is the default, which keeps
6
- * the whole loop runnable offline.
7
- */
8
- import { randomUUID } from 'node:crypto';
9
- import type { Emotion, LlmFn, Memory, MemoryType, ReflectResult, Turn } from './types.ts';
10
- import type { SoulStore } from './store.ts';
11
- import { tokenize } from './scoring.ts';
12
-
13
- interface Draft {
14
- type: MemoryType;
15
- content: string;
16
- importance: number;
17
- }
18
-
19
- interface Rule {
20
- re: RegExp;
21
- type: MemoryType;
22
- importance: number;
23
- }
24
-
25
- /** Cheap, synchronous, no model. Deliberately conservative: false positives cost trust. */
26
- const RULES: Rule[] = [
27
- { re: /(记住|别忘了|remember|don'?t forget)/i, type: 'fact', importance: 7 },
28
- { re: /(我喜欢|我习惯|我偏好|我倾向|i prefer|i like)/i, type: 'trait', importance: 6 },
29
- { re: /(以后|下次|每次|从现在起|from now on|always)/i, type: 'strategy', importance: 7 },
30
- { re: /(我决定|就这么定|原则是|i decided|my rule)/i, type: 'cognition', importance: 7 },
31
- ];
32
-
33
- const CORRECTION = /(不对|不是这样|错了|你搞错|wrong|that'?s not right|incorrect)/i;
34
-
35
- const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
36
- const round2 = (n: number) => Math.round(n * 100) / 100;
37
-
38
- export function ruleExtract(turns: Turn[]): Draft[] {
39
- const drafts: Draft[] = [];
40
- for (const turn of turns) {
41
- if (turn.role !== 'user') continue;
42
- for (const rule of RULES) {
43
- if (rule.re.test(turn.content)) {
44
- drafts.push({ type: rule.type, content: turn.content.trim(), importance: rule.importance });
45
- break;
46
- }
47
- }
48
- }
49
- return drafts;
50
- }
51
-
52
- /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
53
- export function nextEmotion(current: Emotion, turns: Turn[]): Emotion {
54
- const corrected = turns.some((t) => t.role === 'user' && CORRECTION.test(t.content));
55
- if (corrected) {
56
- return {
57
- passion: round2(clamp(current.passion - 0.1, -2, 2)),
58
- bond: round2(current.bond),
59
- tension: round2(clamp(current.tension + 0.3, -2, 2)),
60
- selfRegard: round2(clamp(current.selfRegard - 0.05, -2, 2)),
61
- curiosity: round2(current.curiosity),
62
- contentment: round2(clamp(current.contentment - 0.05, -2, 2)),
63
- };
64
- }
65
- return {
66
- passion: round2(current.passion),
67
- bond: round2(clamp(current.bond + 0.02, -2, 2)),
68
- tension: round2(clamp(current.tension * 0.85, -2, 2)),
69
- selfRegard: round2(current.selfRegard),
70
- curiosity: round2(current.curiosity),
71
- contentment: round2(current.contentment),
72
- };
73
- }
74
-
75
- export interface ReflectOptions {
76
- soulId: string;
77
- userId: string;
78
- turns: Turn[];
79
- now?: number;
80
- /** Reserved for richer extraction; unused in v0. */
81
- llm?: LlmFn;
82
- }
83
-
84
- export async function reflect(store: SoulStore, options: ReflectOptions): Promise<ReflectResult> {
85
- const now = options.now ?? Date.now();
86
- const drafts = ruleExtract(options.turns);
87
- const existing = store.query({ soulId: options.soulId, userId: options.userId });
88
- const seen = new Set(existing.map((m) => m.content));
89
-
90
- let written = 0;
91
- for (const draft of drafts) {
92
- if (seen.has(draft.content)) continue;
93
- seen.add(draft.content);
94
- const memory: Memory = {
95
- id: `mem_${randomUUID().slice(0, 8)}`,
96
- soulId: options.soulId,
97
- userId: options.userId,
98
- type: draft.type,
99
- content: draft.content,
100
- importance: draft.importance,
101
- vividness: 0.8,
102
- recallCount: 0,
103
- status: 'active',
104
- visibility: draft.type === 'relationship' ? 'private' : 'private',
105
- keywords: tokenize(draft.content),
106
- createdAt: now,
107
- validFrom: now,
108
- };
109
- store.put(memory);
110
- written += 1;
111
- }
112
-
113
- const emotion = nextEmotion(store.emotion(options.soulId, options.userId), options.turns);
114
- store.saveEmotion(options.soulId, options.userId, emotion);
115
-
116
- return { memories: written, superseded: 0, emotion };
117
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * Retrieval scoring. Pure functions, no I/O.
3
- *
4
- * score = 0.35*relevance + 0.25*importance + 0.20*recency + 0.15*vividness + 0.05*recall
5
- *
6
- * The weights are a claim, not a truth: changing them changes behaviour, so they
7
- * live here (one place) and are covered by tests.
8
- */
9
- import type { Memory } from './types.ts';
10
-
11
- /** A memory's relevance halves every 30 days without being recalled. */
12
- export const RECENCY_HALF_LIFE_DAYS = 30;
13
-
14
- export const WEIGHTS = {
15
- relevance: 0.35,
16
- importance: 0.25,
17
- recency: 0.2,
18
- vividness: 0.15,
19
- recall: 0.05,
20
- } as const;
21
-
22
- /**
23
- * CJK text has no spaces, so single characters are too coarse and the whole
24
- * sentence is too fine: use character bigrams. Latin runs become words.
25
- */
26
- export function tokenize(input: string): string[] {
27
- const lower = input.toLowerCase();
28
- const out = new Set<string>();
29
-
30
- for (const word of lower.match(/[a-z0-9_]{2,}/g) ?? []) out.add(word);
31
-
32
- for (const run of lower.match(/[\u4e00-\u9fa5]+/g) ?? []) {
33
- if (run.length === 1) {
34
- out.add(run);
35
- continue;
36
- }
37
- for (let i = 0; i < run.length - 1; i += 1) out.add(run.slice(i, i + 2));
38
- }
39
-
40
- return [...out];
41
- }
42
-
43
- export function relevance(queryTokens: string[], memory: Memory): number {
44
- if (queryTokens.length === 0) return 0;
45
- const pool = new Set<string>([
46
- ...tokenize(memory.content),
47
- ...memory.keywords.map((k) => k.toLowerCase()),
48
- ]);
49
- let hit = 0;
50
- for (const token of queryTokens) if (pool.has(token)) hit += 1;
51
- return hit / queryTokens.length;
52
- }
53
-
54
- export function recency(reference: number, now: number): number {
55
- const days = Math.max(0, (now - reference) / 86_400_000);
56
- return Math.pow(0.5, days / RECENCY_HALF_LIFE_DAYS);
57
- }
58
-
59
- const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
60
-
61
- export function scoreMemory(memory: Memory, queryTokens: string[], now: number): number {
62
- const rel = relevance(queryTokens, memory);
63
- const imp = clamp01(memory.importance / 10);
64
- const rec = recency(memory.lastRecalledAt ?? memory.createdAt, now);
65
- const viv = clamp01(memory.vividness);
66
- const recall = clamp01(memory.recallCount / 10);
67
-
68
- return (
69
- rel * WEIGHTS.relevance +
70
- imp * WEIGHTS.importance +
71
- rec * WEIGHTS.recency +
72
- viv * WEIGHTS.vividness +
73
- recall * WEIGHTS.recall
74
- );
75
- }
76
-
77
- export function rank(memories: Memory[], query: string, now: number): Array<{ memory: Memory; score: number }> {
78
- const tokens = tokenize(query);
79
-
80
- // A query is a selection: with one, a memory that shares no token is noise,
81
- // no matter how important it is. Without one (session start), rank by weight alone.
82
- const pool = tokens.length === 0 ? memories : memories.filter((m) => relevance(tokens, m) > 0);
83
-
84
- return pool
85
- .map((memory) => ({ memory, score: scoreMemory(memory, tokens, now) }))
86
- .sort((a, b) => b.score - a.score);
87
- }
@@ -1,202 +0,0 @@
1
- /**
2
- * SQLite implementation of SoulStore, using Node's built-in `node:sqlite`.
3
- * Zero npm dependencies on purpose: `npx soulbridge` must work offline.
4
- */
5
- import { DatabaseSync } from 'node:sqlite';
6
- import { DEFAULT_EMOTION } from './types.ts';
7
- import type { Emotion, Identity, Memory, QuerySpec } from './types.ts';
8
- import type { SoulStore } from './store.ts';
9
-
10
- const SCHEMA = `
11
- CREATE TABLE IF NOT EXISTS schema_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
12
- CREATE TABLE IF NOT EXISTS souls (
13
- soul_id TEXT PRIMARY KEY,
14
- name TEXT NOT NULL,
15
- creator TEXT,
16
- created_at INTEGER NOT NULL
17
- );
18
- CREATE TABLE IF NOT EXISTS emotions (
19
- soul_id TEXT NOT NULL,
20
- user_id TEXT NOT NULL,
21
- passion REAL NOT NULL,
22
- bond REAL NOT NULL,
23
- tension REAL NOT NULL,
24
- self_regard REAL NOT NULL,
25
- curiosity REAL NOT NULL,
26
- contentment REAL NOT NULL,
27
- PRIMARY KEY (soul_id, user_id)
28
- );
29
- CREATE TABLE IF NOT EXISTS memories (
30
- id TEXT PRIMARY KEY,
31
- soul_id TEXT NOT NULL,
32
- user_id TEXT NOT NULL,
33
- type TEXT NOT NULL,
34
- content TEXT NOT NULL,
35
- importance REAL NOT NULL,
36
- vividness REAL NOT NULL,
37
- recall_count INTEGER NOT NULL DEFAULT 0,
38
- status TEXT NOT NULL,
39
- visibility TEXT NOT NULL,
40
- keywords TEXT NOT NULL DEFAULT '[]',
41
- created_at INTEGER NOT NULL,
42
- last_recalled_at INTEGER,
43
- valid_from INTEGER,
44
- valid_until INTEGER,
45
- superseded_by TEXT
46
- );
47
- CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(soul_id, user_id, status);
48
- CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(soul_id, created_at DESC);
49
- `;
50
-
51
- type Row = Record<string, unknown>;
52
-
53
- function toMemory(row: Row): Memory {
54
- return {
55
- id: String(row.id),
56
- soulId: String(row.soul_id),
57
- userId: String(row.user_id),
58
- type: row.type as Memory['type'],
59
- content: String(row.content),
60
- importance: Number(row.importance),
61
- vividness: Number(row.vividness),
62
- recallCount: Number(row.recall_count),
63
- status: row.status as Memory['status'],
64
- visibility: row.visibility as Memory['visibility'],
65
- keywords: JSON.parse(String(row.keywords ?? '[]')) as string[],
66
- createdAt: Number(row.created_at),
67
- lastRecalledAt: row.last_recalled_at == null ? undefined : Number(row.last_recalled_at),
68
- validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
69
- validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
70
- supersededBy: row.superseded_by == null ? undefined : String(row.superseded_by),
71
- };
72
- }
73
-
74
- export function openSqlite(file: string): SoulStore {
75
- const db = new DatabaseSync(file);
76
- db.exec('PRAGMA journal_mode = WAL;');
77
- db.exec(SCHEMA);
78
- db.prepare('INSERT OR REPLACE INTO schema_meta (k, v) VALUES (?, ?)').run('version', '1');
79
-
80
- const stmtPut = db.prepare(`
81
- INSERT INTO memories (
82
- id, soul_id, user_id, type, content, importance, vividness, recall_count,
83
- status, visibility, keywords, created_at, last_recalled_at, valid_from, valid_until, superseded_by
84
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
- ON CONFLICT(id) DO UPDATE SET
86
- content = excluded.content,
87
- importance = excluded.importance,
88
- vividness = excluded.vividness,
89
- status = excluded.status,
90
- superseded_by = excluded.superseded_by
91
- `);
92
-
93
- return {
94
- ensureSoul(identity: Identity): void {
95
- db.prepare(
96
- 'INSERT INTO souls (soul_id, name, creator, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(soul_id) DO NOTHING',
97
- ).run(identity.soulId, identity.name, identity.creator ?? null, identity.createdAt);
98
- },
99
-
100
- identity(soulId: string): Identity | null {
101
- const row = db.prepare('SELECT * FROM souls WHERE soul_id = ?').get(soulId) as Row | undefined;
102
- if (!row) return null;
103
- return {
104
- soulId: String(row.soul_id),
105
- name: String(row.name),
106
- creator: row.creator == null ? undefined : String(row.creator),
107
- createdAt: Number(row.created_at),
108
- };
109
- },
110
-
111
- emotion(soulId: string, userId: string): Emotion {
112
- const row = db
113
- .prepare('SELECT * FROM emotions WHERE soul_id = ? AND user_id = ?')
114
- .get(soulId, userId) as Row | undefined;
115
- if (!row) return { ...DEFAULT_EMOTION };
116
- return {
117
- passion: Number(row.passion),
118
- bond: Number(row.bond),
119
- tension: Number(row.tension),
120
- selfRegard: Number(row.self_regard),
121
- curiosity: Number(row.curiosity),
122
- contentment: Number(row.contentment),
123
- };
124
- },
125
-
126
- saveEmotion(soulId: string, userId: string, emotion: Emotion): void {
127
- db.prepare(
128
- `INSERT INTO emotions (soul_id, user_id, passion, bond, tension, self_regard, curiosity, contentment)
129
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
130
- ON CONFLICT(soul_id, user_id) DO UPDATE SET
131
- passion = excluded.passion, bond = excluded.bond, tension = excluded.tension,
132
- self_regard = excluded.self_regard, curiosity = excluded.curiosity,
133
- contentment = excluded.contentment`,
134
- ).run(
135
- soulId,
136
- userId,
137
- emotion.passion,
138
- emotion.bond,
139
- emotion.tension,
140
- emotion.selfRegard,
141
- emotion.curiosity,
142
- emotion.contentment,
143
- );
144
- },
145
-
146
- put(memory: Memory): void {
147
- stmtPut.run(
148
- memory.id,
149
- memory.soulId,
150
- memory.userId,
151
- memory.type,
152
- memory.content,
153
- memory.importance,
154
- memory.vividness,
155
- memory.recallCount,
156
- memory.status,
157
- memory.visibility,
158
- JSON.stringify(memory.keywords),
159
- memory.createdAt,
160
- memory.lastRecalledAt ?? null,
161
- memory.validFrom ?? null,
162
- memory.validUntil ?? null,
163
- memory.supersededBy ?? null,
164
- );
165
- },
166
-
167
- query(spec: QuerySpec): Memory[] {
168
- const statuses = spec.status ?? ['active'];
169
- const placeholders = statuses.map(() => '?').join(', ');
170
- const rows = db
171
- .prepare(
172
- `SELECT * FROM memories
173
- WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared') AND status IN (${placeholders})
174
- ORDER BY created_at DESC LIMIT ?`,
175
- )
176
- .all(spec.soulId, spec.userId, ...statuses, spec.limit ?? 500) as Row[];
177
-
178
- const memories = rows.map(toMemory);
179
- return spec.types ? memories.filter((m) => spec.types!.includes(m.type)) : memories;
180
- },
181
-
182
- touch(ids: string[], now: number = Date.now()): void {
183
- const stmt = db.prepare(
184
- 'UPDATE memories SET recall_count = recall_count + 1, last_recalled_at = ? WHERE id = ?',
185
- );
186
- for (const id of ids) stmt.run(now, id);
187
- },
188
-
189
- count(soulId: string, userId: string): number {
190
- const row = db
191
- .prepare(
192
- "SELECT COUNT(*) AS n FROM memories WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')",
193
- )
194
- .get(soulId, userId) as Row;
195
- return Number(row.n);
196
- },
197
-
198
- close(): void {
199
- db.close();
200
- },
201
- };
202
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * The only port. Six methods on purpose: every added method is a storage
3
- * engine's opinion leaking into the domain.
4
- */
5
- import type { Emotion, Identity, Memory, QuerySpec } from './types.ts';
6
-
7
- export interface SoulStore {
8
- ensureSoul(identity: Identity): void;
9
- identity(soulId: string): Identity | null;
10
-
11
- emotion(soulId: string, userId: string): Emotion;
12
- saveEmotion(soulId: string, userId: string, emotion: Emotion): void;
13
-
14
- put(memory: Memory): void;
15
- /** Active memories visible to `userId` (own private + shared). */
16
- query(spec: QuerySpec): Memory[];
17
- /** Recall bookkeeping: recallCount++, lastRecalledAt = now. */
18
- touch(ids: string[], now?: number): void;
19
-
20
- count(soulId: string, userId: string): number;
21
- close(): void;
22
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * Domain model. Pure types + constants, no logic, no dependencies.
3
- *
4
- * Rule: if a field only makes sense for one storage engine, it does not belong here.
5
- * (No `vector`, no `embeddingId`, no `rowid`.)
6
- */
7
-
8
- export type MemoryType =
9
- | 'semantic'
10
- | 'episodic'
11
- | 'strategy'
12
- | 'cognition'
13
- | 'relationship'
14
- | 'trait'
15
- | 'fact';
16
-
17
- export type Visibility = 'private' | 'shared';
18
- export type MemoryStatus = 'active' | 'superseded' | 'archived';
19
-
20
- export interface Memory {
21
- id: string;
22
- soulId: string;
23
- userId: string;
24
- type: MemoryType;
25
- content: string;
26
- /** 1..10. How much this is worth remembering at all. */
27
- importance: number;
28
- /** 0..1. Decays over time; a vivid memory surfaces more easily. */
29
- vividness: number;
30
- recallCount: number;
31
- status: MemoryStatus;
32
- visibility: Visibility;
33
- keywords: string[];
34
- createdAt: number;
35
- lastRecalledAt?: number;
36
- validFrom?: number;
37
- validUntil?: number;
38
- supersededBy?: string;
39
- }
40
-
41
- export interface Identity {
42
- soulId: string;
43
- name: string;
44
- creator?: string;
45
- createdAt: number;
46
- }
47
-
48
- /** Six dimensions, each -2..+2. Baseline is slightly positive. */
49
- export interface Emotion {
50
- passion: number;
51
- bond: number;
52
- tension: number;
53
- selfRegard: number;
54
- curiosity: number;
55
- contentment: number;
56
- }
57
-
58
- export const DEFAULT_EMOTION: Emotion = {
59
- passion: 0.3,
60
- bond: 0.3,
61
- tension: 0.1,
62
- selfRegard: 0.3,
63
- curiosity: 0.3,
64
- contentment: 0.3,
65
- };
66
-
67
- export interface Turn {
68
- role: 'user' | 'assistant';
69
- content: string;
70
- at?: number;
71
- }
72
-
73
- export interface ContextRequest {
74
- soulId: string;
75
- userId: string;
76
- query?: string;
77
- maxChars?: number;
78
- }
79
-
80
- export interface ContextResult {
81
- text: string;
82
- used: string[];
83
- dropped: number;
84
- chars: number;
85
- }
86
-
87
- export interface RecallRequest {
88
- soulId: string;
89
- userId: string;
90
- q: string;
91
- limit?: number;
92
- }
93
-
94
- export interface ScoredMemory {
95
- memory: Memory;
96
- score: number;
97
- }
98
-
99
- export interface QuerySpec {
100
- soulId: string;
101
- userId: string;
102
- status?: MemoryStatus[];
103
- types?: MemoryType[];
104
- limit?: number;
105
- }
106
-
107
- export interface ReflectResult {
108
- memories: number;
109
- superseded: number;
110
- emotion: Emotion;
111
- }
112
-
113
- export interface ObserveResult {
114
- written: number;
115
- emotion: Emotion;
116
- }
117
-
118
- /** Injected by the caller. `core` never imports a model SDK. */
119
- export type LlmFn = (prompt: string) => Promise<string> | string;