soulbridge 0.0.10 → 0.0.11

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.
@@ -56,7 +56,10 @@ export declare class Soul {
56
56
  * The analyzer may be async (a model); any failure degrades to the lexicon
57
57
  * analyzer. Emotion is computed from the turn only — never from affect.
58
58
  */
59
- observe(turn: Turn): Promise<ObserveResult>;
59
+ observe(turn: Turn, options?: {
60
+ llm?: LlmFn;
61
+ maxLlmChars?: number;
62
+ }): Promise<ObserveResult>;
60
63
  /**
61
64
  * Explicit form of `observe`: the host decided this is worth keeping.
62
65
  * Same write path, no extraction heuristics.
@@ -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;
@@ -79,16 +80,38 @@ export class Soul {
79
80
  * The analyzer may be async (a model); any failure degrades to the lexicon
80
81
  * analyzer. Emotion is computed from the turn only — never from affect.
81
82
  */
82
- async observe(turn) {
83
+ async observe(turn, options = {}) {
83
84
  const now = turn.at ?? Date.now();
84
85
  const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
85
- const drafts = ruleExtract([turn]);
86
+ // Rules first: they are precise and cheap. Only when they find nothing does
87
+ // the optional model get a chance, so it can add recall without overriding
88
+ // a rule that already fired.
89
+ let drafts = ruleExtract([turn]);
90
+ let extractedBy = drafts.length > 0 ? 'rules' : 'none';
91
+ if (drafts.length === 0 && turn.role === 'user' && options.llm) {
92
+ const limit = options.maxLlmChars ?? 2000;
93
+ if (turn.content.length <= limit) {
94
+ try {
95
+ const draft = await llmExtractTurn(turn.content, options.llm);
96
+ if (draft) {
97
+ drafts = [draft];
98
+ extractedBy = 'llm';
99
+ }
100
+ }
101
+ catch {
102
+ // The model is an enhancement; a transport or parse failure leaves the
103
+ // rule result (nothing) in place.
104
+ }
105
+ }
106
+ }
86
107
  // observe runs every turn; repeating a sentence must not create a second row.
87
108
  const seen = new Set(this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 }).map((m) => m.content.trim()));
109
+ let written = 0;
88
110
  for (const draft of drafts) {
89
111
  if (seen.has(draft.content.trim()))
90
112
  continue;
91
113
  seen.add(draft.content.trim());
114
+ written += 1;
92
115
  this.store.put({
93
116
  id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
94
117
  soulId: this.soulId,
@@ -116,7 +139,7 @@ export class Soul {
116
139
  }
117
140
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
118
141
  this.store.saveEmotion(this.soulId, this.userId, emotion, now);
119
- return { written: drafts.length, emotion, affect };
142
+ return { written, emotion, affect, extractedBy: written > 0 ? extractedBy : 'none' };
120
143
  }
121
144
  /**
122
145
  * Explicit form of `observe`: the host decided this is worth keeping.
@@ -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.11",
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",