soulbridge 0.0.16 → 0.0.18
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.
|
@@ -40,6 +40,11 @@ export interface SoulOptions {
|
|
|
40
40
|
llm?: LlmFn;
|
|
41
41
|
/** Messages longer than this are not sent to the model. Defaults to 2000. */
|
|
42
42
|
llmMaxChars?: number;
|
|
43
|
+
/**
|
|
44
|
+
* A paraphrase written this many ms after a similar memory is treated as a
|
|
45
|
+
* duplicate rather than a second row. Set 0 to disable. Defaults to 30s.
|
|
46
|
+
*/
|
|
47
|
+
dedupeWindowMs?: number;
|
|
43
48
|
}
|
|
44
49
|
export declare class Soul {
|
|
45
50
|
readonly store: SoulStore;
|
|
@@ -50,6 +55,7 @@ export declare class Soul {
|
|
|
50
55
|
readonly analyzer: AffectAnalyzer;
|
|
51
56
|
readonly llm?: LlmFn;
|
|
52
57
|
readonly llmMaxChars: number;
|
|
58
|
+
readonly dedupeWindowMs: number;
|
|
53
59
|
constructor(options: SoulOptions);
|
|
54
60
|
/** Verb 1: session start. */
|
|
55
61
|
context(request?: Partial<ContextRequest>): ContextResult;
|
|
@@ -30,6 +30,7 @@ export class Soul {
|
|
|
30
30
|
analyzer;
|
|
31
31
|
llm;
|
|
32
32
|
llmMaxChars;
|
|
33
|
+
dedupeWindowMs;
|
|
33
34
|
constructor(options) {
|
|
34
35
|
this.store = options.store;
|
|
35
36
|
this.soulId = options.soulId;
|
|
@@ -39,6 +40,7 @@ export class Soul {
|
|
|
39
40
|
this.analyzer = options.analyzer ?? lexiconAnalyzer;
|
|
40
41
|
this.llm = options.llm;
|
|
41
42
|
this.llmMaxChars = options.llmMaxChars ?? 2000;
|
|
43
|
+
this.dedupeWindowMs = options.dedupeWindowMs ?? 30_000;
|
|
42
44
|
if (!this.store.identity(this.soulId)) {
|
|
43
45
|
this.store.ensureSoul({
|
|
44
46
|
soulId: this.soulId,
|
|
@@ -128,6 +130,9 @@ export class Soul {
|
|
|
128
130
|
// a rule that already fired.
|
|
129
131
|
let drafts = ruleExtract([turn]);
|
|
130
132
|
let extractedBy = drafts.length > 0 ? 'rules' : 'none';
|
|
133
|
+
// A model that says "nothing to keep" and a model that answers garbage used
|
|
134
|
+
// to be indistinguishable; keep them apart for measurement.
|
|
135
|
+
let llmOutcome = 'off';
|
|
131
136
|
if (drafts.length === 0 && turn.role === 'user' && this.llm) {
|
|
132
137
|
if (turn.content.length <= this.llmMaxChars) {
|
|
133
138
|
try {
|
|
@@ -135,13 +140,21 @@ export class Soul {
|
|
|
135
140
|
if (draft) {
|
|
136
141
|
drafts = [draft];
|
|
137
142
|
extractedBy = 'llm';
|
|
143
|
+
llmOutcome = 'stored';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
llmOutcome = 'null';
|
|
138
147
|
}
|
|
139
148
|
}
|
|
140
149
|
catch {
|
|
141
150
|
// The model is an enhancement; a transport or parse failure leaves the
|
|
142
151
|
// rule result (nothing) in place.
|
|
152
|
+
llmOutcome = 'failed';
|
|
143
153
|
}
|
|
144
154
|
}
|
|
155
|
+
else {
|
|
156
|
+
llmOutcome = 'skipped';
|
|
157
|
+
}
|
|
145
158
|
}
|
|
146
159
|
// Deterministic write gates: shape -> semantics -> per-turn budget. Whatever
|
|
147
160
|
// they refuse is reported, so written: 0 always has a reason.
|
|
@@ -185,6 +198,7 @@ export class Soul {
|
|
|
185
198
|
written,
|
|
186
199
|
committed: written > 0,
|
|
187
200
|
rejections: gated.rejected,
|
|
201
|
+
llmOutcome,
|
|
188
202
|
emotion,
|
|
189
203
|
affect,
|
|
190
204
|
extractedBy: written > 0 ? extractedBy : 'none',
|
|
@@ -228,17 +242,29 @@ export class Soul {
|
|
|
228
242
|
return { ok: false, reason: rejected[0]?.reason ?? 'rejected by write gates' };
|
|
229
243
|
}
|
|
230
244
|
const content = accepted[0].content;
|
|
231
|
-
const
|
|
232
|
-
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
const candidates = this.store.query({
|
|
233
247
|
soulId: this.soulId,
|
|
234
248
|
userId: this.userId,
|
|
235
249
|
status: ['active'],
|
|
236
250
|
includeExpired: true,
|
|
237
|
-
limit:
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
251
|
+
limit: 50,
|
|
252
|
+
});
|
|
253
|
+
// Same content anywhere: the exact duplicate.
|
|
254
|
+
const same = candidates.find((memory) => memory.content.trim() === content);
|
|
255
|
+
if (same)
|
|
256
|
+
return { ok: true, id: same.id, duplicate: true };
|
|
257
|
+
// A paraphrase of something just written (usually the automatic observer
|
|
258
|
+
// moments earlier) must not become a second row: a shared run of characters
|
|
259
|
+
// inside the dedupe window is treated as the same statement.
|
|
260
|
+
const window = this.dedupeWindowMs;
|
|
261
|
+
if (window > 0) {
|
|
262
|
+
const recent = candidates.filter((memory) => now - memory.createdAt <= window);
|
|
263
|
+
const overlapping = recent.find((memory) => sharesRun(memory.content, content, MIN_SHARED_RUN));
|
|
264
|
+
if (overlapping) {
|
|
265
|
+
return { ok: true, id: overlapping.id, duplicate: true, reason: 'already recorded this turn' };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
242
268
|
const id = this.remember(content, { type, importance });
|
|
243
269
|
return { ok: true, id };
|
|
244
270
|
}
|
|
@@ -274,3 +300,19 @@ export class Soul {
|
|
|
274
300
|
}
|
|
275
301
|
}
|
|
276
302
|
}
|
|
303
|
+
/** Shortest shared character run that means "the same statement" (RFC 0005). */
|
|
304
|
+
const MIN_SHARED_RUN = 4;
|
|
305
|
+
function sharesRun(a, b, minRun) {
|
|
306
|
+
const left = a.trim();
|
|
307
|
+
const right = b.trim();
|
|
308
|
+
if (left.length === 0 || right.length === 0)
|
|
309
|
+
return false;
|
|
310
|
+
const window = Math.min(left.length, right.length);
|
|
311
|
+
for (let len = window; len >= minRun; len -= 1) {
|
|
312
|
+
for (let i = 0; i + len <= right.length; i += 1) {
|
|
313
|
+
if (left.includes(right.slice(i, i + len)))
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
@@ -129,6 +129,11 @@ export interface ObserveResult {
|
|
|
129
129
|
committed: boolean;
|
|
130
130
|
/** Drafts refused before writing, with the gate that refused them. */
|
|
131
131
|
rejections: DraftRejection[];
|
|
132
|
+
/**
|
|
133
|
+
* What the optional model did, kept apart from `extractedBy` because a null
|
|
134
|
+
* answer and a broken answer used to look identical in the logs.
|
|
135
|
+
*/
|
|
136
|
+
llmOutcome: 'off' | 'skipped' | 'stored' | 'null' | 'failed';
|
|
132
137
|
emotion: Emotion;
|
|
133
138
|
/** Analysis of the user turn. Empty signal for assistant turns or on failure. */
|
|
134
139
|
affect: AffectSignal;
|