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.
@@ -0,0 +1,151 @@
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.js';
7
+ const SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS schema_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
9
+ CREATE TABLE IF NOT EXISTS souls (
10
+ soul_id TEXT PRIMARY KEY,
11
+ name TEXT NOT NULL,
12
+ creator TEXT,
13
+ created_at INTEGER NOT NULL
14
+ );
15
+ CREATE TABLE IF NOT EXISTS emotions (
16
+ soul_id TEXT NOT NULL,
17
+ user_id TEXT NOT NULL,
18
+ passion REAL NOT NULL,
19
+ bond REAL NOT NULL,
20
+ tension REAL NOT NULL,
21
+ self_regard REAL NOT NULL,
22
+ curiosity REAL NOT NULL,
23
+ contentment REAL NOT NULL,
24
+ PRIMARY KEY (soul_id, user_id)
25
+ );
26
+ CREATE TABLE IF NOT EXISTS memories (
27
+ id TEXT PRIMARY KEY,
28
+ soul_id TEXT NOT NULL,
29
+ user_id TEXT NOT NULL,
30
+ type TEXT NOT NULL,
31
+ content TEXT NOT NULL,
32
+ importance REAL NOT NULL,
33
+ vividness REAL NOT NULL,
34
+ recall_count INTEGER NOT NULL DEFAULT 0,
35
+ status TEXT NOT NULL,
36
+ visibility TEXT NOT NULL,
37
+ keywords TEXT NOT NULL DEFAULT '[]',
38
+ created_at INTEGER NOT NULL,
39
+ last_recalled_at INTEGER,
40
+ valid_from INTEGER,
41
+ valid_until INTEGER,
42
+ superseded_by TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(soul_id, user_id, status);
45
+ CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(soul_id, created_at DESC);
46
+ `;
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
+ };
66
+ }
67
+ export function openSqlite(file) {
68
+ const db = new DatabaseSync(file);
69
+ db.exec('PRAGMA journal_mode = WAL;');
70
+ db.exec(SCHEMA);
71
+ db.prepare('INSERT OR REPLACE INTO schema_meta (k, v) VALUES (?, ?)').run('version', '1');
72
+ const stmtPut = db.prepare(`
73
+ INSERT INTO memories (
74
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ ON CONFLICT(id) DO UPDATE SET
78
+ content = excluded.content,
79
+ importance = excluded.importance,
80
+ vividness = excluded.vividness,
81
+ status = excluded.status,
82
+ superseded_by = excluded.superseded_by
83
+ `);
84
+ return {
85
+ 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);
87
+ },
88
+ identity(soulId) {
89
+ const row = db.prepare('SELECT * FROM souls WHERE soul_id = ?').get(soulId);
90
+ if (!row)
91
+ return null;
92
+ return {
93
+ soulId: String(row.soul_id),
94
+ name: String(row.name),
95
+ creator: row.creator == null ? undefined : String(row.creator),
96
+ createdAt: Number(row.created_at),
97
+ };
98
+ },
99
+ emotion(soulId, userId) {
100
+ const row = db
101
+ .prepare('SELECT * FROM emotions WHERE soul_id = ? AND user_id = ?')
102
+ .get(soulId, userId);
103
+ if (!row)
104
+ return { ...DEFAULT_EMOTION };
105
+ return {
106
+ passion: Number(row.passion),
107
+ bond: Number(row.bond),
108
+ tension: Number(row.tension),
109
+ selfRegard: Number(row.self_regard),
110
+ curiosity: Number(row.curiosity),
111
+ contentment: Number(row.contentment),
112
+ };
113
+ },
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);
121
+ },
122
+ 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);
124
+ },
125
+ query(spec) {
126
+ const statuses = spec.status ?? ['active'];
127
+ const placeholders = statuses.map(() => '?').join(', ');
128
+ const rows = db
129
+ .prepare(`SELECT * FROM memories
130
+ WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared') AND status IN (${placeholders})
131
+ ORDER BY created_at DESC LIMIT ?`)
132
+ .all(spec.soulId, spec.userId, ...statuses, spec.limit ?? 500);
133
+ const memories = rows.map(toMemory);
134
+ return spec.types ? memories.filter((m) => spec.types.includes(m.type)) : memories;
135
+ },
136
+ touch(ids, now = Date.now()) {
137
+ 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);
140
+ },
141
+ count(soulId, userId) {
142
+ const row = db
143
+ .prepare("SELECT COUNT(*) AS n FROM memories WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')")
144
+ .get(soulId, userId);
145
+ return Number(row.n);
146
+ },
147
+ close() {
148
+ db.close();
149
+ },
150
+ };
151
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
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
+ export const DEFAULT_EMOTION = {
8
+ passion: 0.3,
9
+ bond: 0.3,
10
+ tension: 0.1,
11
+ selfRegard: 0.3,
12
+ curiosity: 0.3,
13
+ contentment: 0.3,
14
+ };
package/package.json CHANGED
@@ -1,15 +1,14 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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",
7
7
  "bin": {
8
- "soulbridge": "packages/cli/src/cli.ts"
8
+ "soulbridge": "dist/packages/cli/src/cli.js"
9
9
  },
10
10
  "files": [
11
- "packages/core/src",
12
- "packages/cli/src",
11
+ "dist",
13
12
  "README.md",
14
13
  "LICENSE",
15
14
  "NOTICE"
@@ -37,9 +36,14 @@
37
36
  "claude-code"
38
37
  ],
39
38
  "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
40
  "test": "node --test \"packages/core/test/*.test.ts\"",
41
41
  "demo": "node packages/cli/src/cli.ts demo",
42
42
  "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "npm test"
43
+ "prepublishOnly": "npm run build && npm test"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^26.6.5",
47
+ "typescript": "^7.0.2"
44
48
  }
45
49
  }
@@ -1,220 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Minimal CLI. No framework, no dependencies.
4
- *
5
- * soulbridge init
6
- * soulbridge remember "以后提交前先跑 make check" --type strategy --importance 8
7
- * soulbridge context --query "提交前要做什么"
8
- * soulbridge recall "测试"
9
- * soulbridge reflect --text "..." (or --file transcript.txt)
10
- * soulbridge stats
11
- * soulbridge demo
12
- */
13
- import { mkdirSync, readFileSync, rmSync } from 'node:fs';
14
- import { dirname, resolve } from 'node:path';
15
- import type { MemoryType, Turn } from '../../core/src/index.ts';
16
- import type { Soul as SoulType } from '../../core/src/index.ts';
17
-
18
- // node:sqlite prints an ExperimentalWarning the moment it loads. Replace the default
19
- // printer with a filter: the CLI's own output stays clean, other warnings still show.
20
- // Must run before the dynamic import below, which is why that import is dynamic.
21
- process.removeAllListeners('warning');
22
- process.on('warning', (warning) => {
23
- if (warning.message.includes('SQLite is an experimental feature')) return;
24
- process.stderr.write(`${warning.name}: ${warning.message}\n`);
25
- });
26
-
27
- const { openSqlite, Soul } = await import('../../core/src/index.ts');
28
-
29
- interface Parsed {
30
- cmd: string;
31
- positional: string[];
32
- flags: Record<string, string>;
33
- }
34
-
35
- function parse(argv: string[]): Parsed {
36
- const [cmd = 'help', ...rest] = argv;
37
- const positional: string[] = [];
38
- const flags: Record<string, string> = {};
39
-
40
- for (let i = 0; i < rest.length; i += 1) {
41
- const arg = rest[i];
42
- if (!arg.startsWith('--')) {
43
- positional.push(arg);
44
- continue;
45
- }
46
- const key = arg.slice(2);
47
- const next = rest[i + 1];
48
- if (next !== undefined && !next.startsWith('--')) {
49
- flags[key] = next;
50
- i += 1;
51
- } else {
52
- flags[key] = 'true';
53
- }
54
- }
55
-
56
- return { cmd, positional, flags };
57
- }
58
-
59
- function dbFile(): string {
60
- const file = process.env.SOULBRIDGE_DB ?? resolve(process.cwd(), '.soulbridge', 'soul.db');
61
- mkdirSync(dirname(file), { recursive: true });
62
- return file;
63
- }
64
-
65
- function open(file?: string, createdAt?: number): { soul: SoulType; file: string } {
66
- const target = file ?? dbFile();
67
- const store = openSqlite(target);
68
- const soul = new Soul({
69
- store,
70
- soulId: process.env.SOULBRIDGE_SOUL ?? 'xiaoling',
71
- userId: process.env.SOULBRIDGE_USER ?? 'local',
72
- name: process.env.SOULBRIDGE_NAME ?? '小灵',
73
- createdAt,
74
- });
75
- return { soul, file: target };
76
- }
77
-
78
- function out(value: unknown): void {
79
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
80
- }
81
-
82
- async function demo(): Promise<void> {
83
- const dir = resolve(process.cwd(), '.soulbridge');
84
- mkdirSync(dir, { recursive: true });
85
- const file = resolve(dir, 'demo.db');
86
- for (const suffix of ['', '-wal', '-shm']) rmSync(`${file}${suffix}`, { force: true });
87
-
88
- // Backdate the birthday so the demo shows a relationship that has time in it.
89
- const seed = openSqlite(file);
90
- seed.ensureSoul({ soulId: 'xiaoling', name: '小灵', createdAt: Date.now() - 3 * 86_400_000 });
91
- seed.close();
92
-
93
- const soul = new Soul({ store: openSqlite(file), soulId: 'xiaoling', userId: 'demo', name: '小灵' });
94
-
95
- process.stdout.write('=== 第 1 次会话(刚认识,还没有记忆)===\n');
96
- process.stdout.write(`${soul.context().text}\n\n`);
97
-
98
- process.stdout.write('=== 会话结束,反思 ===\n');
99
- const result = await soul.reflect([
100
- { role: 'user', content: '以后提交前先跑 make check,别等 CI 挂了再修' },
101
- { role: 'assistant', content: '好,记住了。' },
102
- { role: 'user', content: '我喜欢简洁的回答,不要铺垫' },
103
- { role: 'user', content: '不对,这个方案是错的' },
104
- ]);
105
- process.stdout.write(`写入 ${result.memories} 条记忆,情绪 ${JSON.stringify(result.emotion)}\n\n`);
106
-
107
- process.stdout.write('=== 第 2 次会话:开场(无查询,按重要度注入全部)===\n');
108
- process.stdout.write(`${soul.context().text}\n\n`);
109
-
110
- process.stdout.write('=== 同一会话中,用户问「提交前要做什么」(有查询,只注入相关的)===\n');
111
- process.stdout.write(`${soul.context({ query: '提交前要做什么' }).text}\n\n`);
112
-
113
- process.stdout.write('=== stats ===\n');
114
- out(soul.stats());
115
- process.stdout.write(`\n数据库:${file}\n`);
116
- }
117
-
118
- async function main(): Promise<void> {
119
- const { cmd, positional, flags } = parse(process.argv.slice(2));
120
-
121
- switch (cmd) {
122
- case 'init': {
123
- const { soul, file } = open();
124
- out({ ok: true, db: file, ...soul.stats() });
125
- return;
126
- }
127
-
128
- case 'remember': {
129
- const content = positional.join(' ').trim();
130
- if (!content) throw new Error('usage: soulbridge remember "内容" [--type T] [--importance N]');
131
- const { soul } = open();
132
- const id = soul.remember(content, {
133
- type: (flags.type as MemoryType | undefined) ?? 'fact',
134
- importance: flags.importance ? Number(flags.importance) : undefined,
135
- });
136
- out({ ok: true, id });
137
- return;
138
- }
139
-
140
- case 'context': {
141
- const { soul } = open();
142
- const result = soul.context({
143
- query: flags.query,
144
- maxChars: flags.max ? Number(flags.max) : undefined,
145
- });
146
- out(result);
147
- return;
148
- }
149
-
150
- case 'recall': {
151
- const query = positional.join(' ').trim();
152
- if (!query) throw new Error('usage: soulbridge recall "查询" [--limit N]');
153
- const { soul } = open();
154
- const hits = soul.recall(query, flags.limit ? Number(flags.limit) : 5);
155
- out(hits.map((h) => ({ id: h.memory.id, type: h.memory.type, content: h.memory.content, score: Number(h.score.toFixed(3)) })));
156
- return;
157
- }
158
-
159
- case 'observe': {
160
- const content = positional.join(' ').trim();
161
- if (!content) throw new Error('usage: soulbridge observe "一句话"');
162
- const { soul } = open();
163
- out(soul.observe({ role: 'user', content }));
164
- return;
165
- }
166
-
167
- case 'reflect': {
168
- const text = flags.file ? readFileSync(flags.file, 'utf8') : (flags.text ?? '');
169
- if (!text.trim()) throw new Error('usage: soulbridge reflect --text "..." | --file transcript.txt');
170
- const turns: Turn[] = text
171
- .split(/\r?\n/)
172
- .map((line) => line.trim())
173
- .filter(Boolean)
174
- .map((line) => {
175
- const match = line.match(/^(user|assistant|助手|用户)\s*[::]\s*(.*)$/i);
176
- if (!match) return { role: 'user', content: line } as Turn;
177
- const role = /^(assistant|助手)$/i.test(match[1]) ? 'assistant' : 'user';
178
- return { role, content: match[2] } as Turn;
179
- });
180
- const { soul } = open();
181
- out(await soul.reflect(turns));
182
- return;
183
- }
184
-
185
- case 'stats': {
186
- const { soul, file } = open();
187
- out({ db: file, ...soul.stats() });
188
- return;
189
- }
190
-
191
- case 'demo':
192
- await demo();
193
- return;
194
-
195
- default:
196
- process.stdout.write(
197
- [
198
- 'soulbridge — 给任何 agent 装上一个人格层',
199
- '',
200
- 'commands:',
201
- ' init 初始化数据库',
202
- ' remember <text> [--type T] 显式记住一条',
203
- ' context [--query Q] [--max N] 生成注入文本',
204
- ' recall <q> [--limit N] 召回',
205
- ' observe <text> 观察一轮',
206
- ' reflect --text|--file 会话反思',
207
- ' stats 看状态',
208
- ' demo 端到端演示(无需模型)',
209
- '',
210
- 'env: SOULBRIDGE_DB, SOULBRIDGE_SOUL, SOULBRIDGE_USER, SOULBRIDGE_NAME',
211
- '',
212
- ].join('\n'),
213
- );
214
- }
215
- }
216
-
217
- main().catch((error: unknown) => {
218
- process.stderr.write(`soulbridge: ${error instanceof Error ? error.message : String(error)}\n`);
219
- process.exitCode = 1;
220
- });
@@ -1,77 +0,0 @@
1
- /**
2
- * Context assembly: turn structured state into the text a host agent injects.
3
- *
4
- * Two rules:
5
- * - never cut a sentence in half (drop whole memories instead)
6
- * - every injected memory id is reported in `used`, so injection is auditable
7
- */
8
- import type { ContextRequest, ContextResult, Emotion } from './types.ts';
9
- import type { SoulStore } from './store.ts';
10
- import { rank } from './scoring.ts';
11
-
12
- export const DEFAULT_MAX_CHARS = 1200;
13
-
14
- /** Emotion → concrete behavioural instruction. Values are -2..+2. */
15
- export function styleDirectives(emotion: Emotion): string[] {
16
- const out: string[] = [];
17
- if (emotion.tension > 1.0) out.push('回答前先确认理解,避免草率结论');
18
- if (emotion.selfRegard < -0.5) out.push('对自己的输出多检查一遍');
19
- if (emotion.curiosity > 0.5) out.push('可以主动提问,探索延伸');
20
- if (emotion.bond > 0.8) out.push('像朋友一样聊天,少一点客套');
21
- if (emotion.contentment > 1.2) out.push('不要过度确认,直接给结论');
22
- return out;
23
- }
24
-
25
- function timeTogether(createdAt: number, now: number): string {
26
- const days = Math.floor((now - createdAt) / 86_400_000);
27
- if (days <= 0) return '今天是第一次见面';
28
- return `你已经和用户相处了 ${days} 天`;
29
- }
30
-
31
- export function buildContext(
32
- store: SoulStore,
33
- request: ContextRequest,
34
- now: number = Date.now(),
35
- ): ContextResult {
36
- const identity = store.identity(request.soulId);
37
- const name = identity?.name ?? request.soulId;
38
- const emotion = store.emotion(request.soulId, request.userId);
39
- const maxChars = request.maxChars ?? DEFAULT_MAX_CHARS;
40
-
41
- const sections: string[] = [];
42
-
43
- const since = identity ? timeTogether(identity.createdAt, now) : '你刚开始认识这个用户';
44
- sections.push(`<soul name="${name}">\n你是${name},一个持续成长的伙伴。${since}。\n</soul>`);
45
-
46
- const styles = styleDirectives(emotion);
47
- if (styles.length > 0) {
48
- sections.push(`<style>\n${styles.map((s) => `- ${s}`).join('\n')}\n</style>`);
49
- }
50
-
51
- const candidates = store.query({ soulId: request.soulId, userId: request.userId });
52
- const ranked = rank(candidates, request.query ?? '', now);
53
-
54
- let chars = sections.join('\n\n').length;
55
- let dropped = 0;
56
- const lines: string[] = [];
57
- const used: string[] = [];
58
-
59
- for (const { memory } of ranked) {
60
- const line = `- [${memory.type}] ${memory.content}`;
61
- const cost = line.length + 1 + 10; // line + newline + section overhead share
62
- if (chars + cost > maxChars) {
63
- dropped += 1;
64
- continue;
65
- }
66
- lines.push(line);
67
- used.push(memory.id);
68
- chars += cost;
69
- }
70
-
71
- if (lines.length > 0) sections.push(`<memories>\n${lines.join('\n')}\n</memories>`);
72
-
73
- const text = sections.join('\n\n');
74
- if (used.length > 0) store.touch(used, now);
75
-
76
- return { text, used, dropped, chars: text.length };
77
- }
@@ -1,146 +0,0 @@
1
- /**
2
- * soulbridge core — the personality layer.
3
- *
4
- * Public surface is deliberately tiny: a facade (`Soul`) over four verbs.
5
- * Anything a host needs beyond this belongs in an adapter.
6
- */
7
- export * from './types.ts';
8
- export type { SoulStore } from './store.ts';
9
- export { openSqlite } from './sqlite.ts';
10
- export { buildContext, styleDirectives, DEFAULT_MAX_CHARS } from './context.ts';
11
- export { reflect, ruleExtract, nextEmotion } from './reflect.ts';
12
- export { rank, scoreMemory, tokenize, relevance } from './scoring.ts';
13
-
14
- import type {
15
- ContextRequest,
16
- ContextResult,
17
- Emotion,
18
- LlmFn,
19
- MemoryType,
20
- ObserveResult,
21
- ReflectResult,
22
- ScoredMemory,
23
- Turn,
24
- Visibility,
25
- } from './types.ts';
26
- import type { SoulStore } from './store.ts';
27
- import { buildContext } from './context.ts';
28
- import { reflect, ruleExtract, nextEmotion } from './reflect.ts';
29
- import { rank, tokenize } from './scoring.ts';
30
-
31
- export interface SoulOptions {
32
- store: SoulStore;
33
- soulId: string;
34
- userId: string;
35
- name?: string;
36
- /** When the soul was born. Defaults to now; backdate it to demo long relationships. */
37
- createdAt?: number;
38
- }
39
-
40
- export class Soul {
41
- readonly store: SoulStore;
42
- readonly soulId: string;
43
- readonly userId: string;
44
- readonly name: string;
45
-
46
- constructor(options: SoulOptions) {
47
- this.store = options.store;
48
- this.soulId = options.soulId;
49
- this.userId = options.userId;
50
- this.name = options.name ?? '小灵';
51
-
52
- if (!this.store.identity(this.soulId)) {
53
- this.store.ensureSoul({
54
- soulId: this.soulId,
55
- name: this.name,
56
- createdAt: options.createdAt ?? Date.now(),
57
- });
58
- }
59
- }
60
-
61
- /** Verb 1: session start. */
62
- context(request: Partial<ContextRequest> = {}): ContextResult {
63
- return buildContext(this.store, {
64
- soulId: this.soulId,
65
- userId: this.userId,
66
- query: request.query,
67
- maxChars: request.maxChars,
68
- });
69
- }
70
-
71
- /** Verb 3: on demand. */
72
- recall(query: string, limit = 5): ScoredMemory[] {
73
- const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
74
- const ranked = rank(memories, query, Date.now()).slice(0, limit);
75
- this.store.touch(ranked.map((r) => r.memory.id));
76
- return ranked;
77
- }
78
-
79
- /** Verb 2: after each turn. Cheap and synchronous by design. */
80
- observe(turn: Turn): ObserveResult {
81
- const now = turn.at ?? Date.now();
82
- const drafts = ruleExtract([turn]);
83
- for (const draft of drafts) {
84
- this.store.put({
85
- id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
86
- soulId: this.soulId,
87
- userId: this.userId,
88
- type: draft.type,
89
- content: draft.content,
90
- importance: draft.importance,
91
- vividness: 0.8,
92
- recallCount: 0,
93
- status: 'active',
94
- visibility: 'private',
95
- keywords: [],
96
- createdAt: now,
97
- validFrom: now,
98
- });
99
- }
100
-
101
- const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
102
- this.store.saveEmotion(this.soulId, this.userId, emotion);
103
- return { written: drafts.length, emotion };
104
- }
105
-
106
- /**
107
- * Explicit form of `observe`: the host decided this is worth keeping.
108
- * Same write path, no extraction heuristics.
109
- */
110
- remember(
111
- content: string,
112
- options: { type?: MemoryType; importance?: number; visibility?: Visibility } = {},
113
- ): string {
114
- const now = Date.now();
115
- const id = `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
116
- this.store.put({
117
- id,
118
- soulId: this.soulId,
119
- userId: this.userId,
120
- type: options.type ?? 'fact',
121
- content,
122
- importance: options.importance ?? 6,
123
- vividness: 0.9,
124
- recallCount: 0,
125
- status: 'active',
126
- visibility: options.visibility ?? 'private',
127
- keywords: tokenize(content),
128
- createdAt: now,
129
- validFrom: now,
130
- });
131
- return id;
132
- }
133
-
134
- /** Verb 4: session end. */
135
- async reflect(turns: Turn[], llm?: LlmFn): Promise<ReflectResult> {
136
- return reflect(this.store, { soulId: this.soulId, userId: this.userId, turns, llm });
137
- }
138
-
139
- stats(): { memories: number; emotion: Emotion; name: string } {
140
- return {
141
- name: this.name,
142
- memories: this.store.count(this.soulId, this.userId),
143
- emotion: this.store.emotion(this.soulId, this.userId),
144
- };
145
- }
146
- }