soulbridge 0.0.9 → 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
|
|
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,11 +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
|
-
|
|
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
|
+
}
|
|
107
|
+
// observe runs every turn; repeating a sentence must not create a second row.
|
|
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;
|
|
86
110
|
for (const draft of drafts) {
|
|
111
|
+
if (seen.has(draft.content.trim()))
|
|
112
|
+
continue;
|
|
113
|
+
seen.add(draft.content.trim());
|
|
114
|
+
written += 1;
|
|
87
115
|
this.store.put({
|
|
88
116
|
id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
|
|
89
117
|
soulId: this.soulId,
|
|
@@ -111,7 +139,7 @@ export class Soul {
|
|
|
111
139
|
}
|
|
112
140
|
const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
|
|
113
141
|
this.store.saveEmotion(this.soulId, this.userId, emotion, now);
|
|
114
|
-
return { written
|
|
142
|
+
return { written, emotion, affect, extractedBy: written > 0 ? extractedBy : 'none' };
|
|
115
143
|
}
|
|
116
144
|
/**
|
|
117
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 =
|
|
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;
|