soulbridge 0.0.10 → 0.0.12

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.
@@ -31,6 +31,14 @@ export interface SoulOptions {
31
31
  deviceId?: string;
32
32
  /** Defaults to the lexicon analyzer; pass an LLM-backed one to opt in (ADR 0015). */
33
33
  analyzer?: AffectAnalyzer;
34
+ /**
35
+ * Optional model access, injected the same way as `analyzer`. Adapters supply
36
+ * it from their host's own model (an in-process service, a CLI subprocess, an
37
+ * HTTP endpoint). Omit it and every verb stays rule-only.
38
+ */
39
+ llm?: LlmFn;
40
+ /** Messages longer than this are not sent to the model. Defaults to 2000. */
41
+ llmMaxChars?: number;
34
42
  }
35
43
  export declare class Soul {
36
44
  readonly store: SoulStore;
@@ -39,6 +47,8 @@ export declare class Soul {
39
47
  readonly name: string;
40
48
  readonly deviceId: string;
41
49
  readonly analyzer: AffectAnalyzer;
50
+ readonly llm?: LlmFn;
51
+ readonly llmMaxChars: number;
42
52
  constructor(options: SoulOptions);
43
53
  /** Verb 1: session start. */
44
54
  context(request?: Partial<ContextRequest>): ContextResult;
@@ -68,7 +78,6 @@ export declare class Soul {
68
78
  }): string;
69
79
  /** Verb 4: session end. */
70
80
  reflect(turns: Turn[], options?: {
71
- llm?: LlmFn;
72
81
  updateEmotion?: boolean;
73
82
  }): Promise<ReflectResult>;
74
83
  stats(): {
@@ -19,6 +19,7 @@ import { buildContext } from './context.js';
19
19
  import { reflect, ruleExtract, nextEmotion } from './reflect.js';
20
20
  import { rank, tokenize } from './scoring.js';
21
21
  import { analyzeAffect, emptyAffect, lexiconAnalyzer } from './affect/analyze.js';
22
+ import { llmExtractTurn } from './reflect.js';
22
23
  export class Soul {
23
24
  store;
24
25
  soulId;
@@ -26,6 +27,8 @@ export class Soul {
26
27
  name;
27
28
  deviceId;
28
29
  analyzer;
30
+ llm;
31
+ llmMaxChars;
29
32
  constructor(options) {
30
33
  this.store = options.store;
31
34
  this.soulId = options.soulId;
@@ -33,6 +36,8 @@ export class Soul {
33
36
  this.name = options.name ?? '小灵';
34
37
  this.deviceId = options.deviceId ?? 'dev_local';
35
38
  this.analyzer = options.analyzer ?? lexiconAnalyzer;
39
+ this.llm = options.llm;
40
+ this.llmMaxChars = options.llmMaxChars ?? 2000;
36
41
  if (!this.store.identity(this.soulId)) {
37
42
  this.store.ensureSoul({
38
43
  soulId: this.soulId,
@@ -82,13 +87,34 @@ export class Soul {
82
87
  async observe(turn) {
83
88
  const now = turn.at ?? Date.now();
84
89
  const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
85
- const drafts = ruleExtract([turn]);
90
+ // Rules first: they are precise and cheap. Only when they find nothing does
91
+ // the optional model get a chance, so it can add recall without overriding
92
+ // a rule that already fired.
93
+ let drafts = ruleExtract([turn]);
94
+ let extractedBy = drafts.length > 0 ? 'rules' : 'none';
95
+ if (drafts.length === 0 && turn.role === 'user' && this.llm) {
96
+ if (turn.content.length <= this.llmMaxChars) {
97
+ try {
98
+ const draft = await llmExtractTurn(turn.content, this.llm);
99
+ if (draft) {
100
+ drafts = [draft];
101
+ extractedBy = 'llm';
102
+ }
103
+ }
104
+ catch {
105
+ // The model is an enhancement; a transport or parse failure leaves the
106
+ // rule result (nothing) in place.
107
+ }
108
+ }
109
+ }
86
110
  // observe runs every turn; repeating a sentence must not create a second row.
87
111
  const seen = new Set(this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 }).map((m) => m.content.trim()));
112
+ let written = 0;
88
113
  for (const draft of drafts) {
89
114
  if (seen.has(draft.content.trim()))
90
115
  continue;
91
116
  seen.add(draft.content.trim());
117
+ written += 1;
92
118
  this.store.put({
93
119
  id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
94
120
  soulId: this.soulId,
@@ -116,7 +142,7 @@ export class Soul {
116
142
  }
117
143
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
118
144
  this.store.saveEmotion(this.soulId, this.userId, emotion, now);
119
- return { written: drafts.length, emotion, affect };
145
+ return { written, emotion, affect, extractedBy: written > 0 ? extractedBy : 'none' };
120
146
  }
121
147
  /**
122
148
  * Explicit form of `observe`: the host decided this is worth keeping.
@@ -149,7 +175,7 @@ export class Soul {
149
175
  soulId: this.soulId,
150
176
  userId: this.userId,
151
177
  turns,
152
- llm: options.llm,
178
+ llm: this.llm,
153
179
  updateEmotion: options.updateEmotion,
154
180
  });
155
181
  }
@@ -44,4 +44,18 @@ export interface ReflectOptions {
44
44
  llm?: LlmFn;
45
45
  }
46
46
  export declare function reflect(store: SoulStore, options: ReflectOptions): Promise<ReflectResult>;
47
+ /** Only these types travel over the port; episodic/relationship stay host-local. */
48
+ export declare const LLM_MEMORY_TYPES: MemoryType[];
49
+ export declare const LLM_PROMPT: string;
50
+ export interface LlmDraft {
51
+ type: MemoryType;
52
+ content: string;
53
+ importance: number;
54
+ }
55
+ /**
56
+ * Ask the model for one memory. Returns null when the model deliberately says
57
+ * "nothing worth storing"; throws when the transport or the output is unusable,
58
+ * so the caller can fall back to rules.
59
+ */
60
+ export declare function llmExtractTurn(text: string, llm: LlmFn): Promise<LlmDraft | null>;
47
61
  export {};
@@ -118,7 +118,26 @@ export function nextEmotion(current, turns) {
118
118
  }
119
119
  export async function reflect(store, options) {
120
120
  const now = options.now ?? Date.now();
121
- const drafts = ruleExtract(options.turns);
121
+ const drafts = [];
122
+ if (options.llm) {
123
+ for (const turn of options.turns) {
124
+ if (turn.role !== 'user')
125
+ continue;
126
+ try {
127
+ const draft = await llmExtractTurn(turn.content, options.llm);
128
+ // A deliberate null means the model decided this is not worth storing.
129
+ if (draft)
130
+ drafts.push(draft);
131
+ }
132
+ catch {
133
+ // Transport or parse failure: fall back to rules for this turn.
134
+ drafts.push(...ruleExtract([turn]));
135
+ }
136
+ }
137
+ }
138
+ else {
139
+ drafts.push(...ruleExtract(options.turns));
140
+ }
122
141
  const existing = store.query({ soulId: options.soulId, userId: options.userId });
123
142
  const seen = new Set(existing.map((m) => m.content));
124
143
  let written = 0;
@@ -151,3 +170,60 @@ export async function reflect(store, options) {
151
170
  }
152
171
  return { memories: written, superseded: 0, emotion };
153
172
  }
173
+ /* ------------------------------------------------------------------ *
174
+ * LLM distillation port (optional)
175
+ *
176
+ * Core owns the prompt, the JSON contract and the fallback; adapters own the
177
+ * transport. A DSH adapter wraps `ctx.llm.stream`, a CLI adapter calls an
178
+ * OpenAI-compatible endpoint, and both satisfy the same `LlmFn`.
179
+ * ------------------------------------------------------------------ */
180
+ /** Only these types travel over the port; episodic/relationship stay host-local. */
181
+ export const LLM_MEMORY_TYPES = ['strategy', 'trait', 'fact', 'cognition'];
182
+ export const LLM_PROMPT = [
183
+ '你是记忆提炼器。输入是用户的一段话,判断其中有没有值得长期记住的内容。',
184
+ '只输出 JSON,二选一:',
185
+ '{"type":"strategy|trait|fact|cognition|null","text":"不超过 40 字的记忆"}',
186
+ '规则:',
187
+ '1. 只保留长期有效的要求、偏好或事实;一次性的抱怨、讨论、玩笑都输出 null;',
188
+ '2. 保留条件(什么时候)和动作(做什么);',
189
+ '3. 不要解释,不要编造原文没有的内容。',
190
+ ].join('\n');
191
+ function parseLlmJson(raw) {
192
+ const trimmed = raw.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
193
+ const start = trimmed.indexOf('{');
194
+ const end = trimmed.lastIndexOf('}');
195
+ if (start < 0 || end < start)
196
+ return null;
197
+ try {
198
+ const parsed = JSON.parse(trimmed.slice(start, end + 1));
199
+ return parsed;
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ /**
206
+ * Ask the model for one memory. Returns null when the model deliberately says
207
+ * "nothing worth storing"; throws when the transport or the output is unusable,
208
+ * so the caller can fall back to rules.
209
+ */
210
+ export async function llmExtractTurn(text, llm) {
211
+ const raw = await llm(`${LLM_PROMPT}\n\n用户:${text}`);
212
+ const parsed = parseLlmJson(String(raw));
213
+ if (!parsed)
214
+ throw new Error('llm returned no JSON');
215
+ const type = parsed.type;
216
+ if (type === 'null' || parsed.text === undefined || parsed.text === null)
217
+ return null;
218
+ if (typeof type !== 'string' || !LLM_MEMORY_TYPES.includes(type)) {
219
+ throw new Error(`llm returned an unsupported type: ${String(type)}`);
220
+ }
221
+ const content = String(parsed.text).trim();
222
+ if (content.length === 0)
223
+ return null;
224
+ return {
225
+ type: type,
226
+ content: content.slice(0, 120),
227
+ importance: type === 'strategy' ? 7 : 6,
228
+ };
229
+ }
@@ -108,6 +108,8 @@ export interface ObserveResult {
108
108
  emotion: Emotion;
109
109
  /** Analysis of the user turn. Empty signal for assistant turns or on failure. */
110
110
  affect: AffectSignal;
111
+ /** Which extractor produced the memory, for measurement. */
112
+ extractedBy: 'rules' | 'llm' | 'none';
111
113
  }
112
114
  /** Injected by the caller. `core` never imports a model SDK. */
113
115
  export type LlmFn = (prompt: string) => Promise<string> | string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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",