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 existing = this.store
232
- .query({
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: 10_000,
238
- })
239
- .find((memory) => memory.content.trim() === content);
240
- if (existing)
241
- return { ok: true, id: existing.id, duplicate: true };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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",