soulbridge 0.0.26 → 0.0.98

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.
Files changed (44) hide show
  1. package/LICENSE +202 -202
  2. package/dist/packages/cli/src/cli.js +0 -0
  3. package/dist/packages/core/src/config/dimensions.d.ts +18 -0
  4. package/dist/packages/core/src/config/dimensions.js +33 -0
  5. package/dist/packages/core/src/config/domains.d.ts +70 -0
  6. package/dist/packages/core/src/config/domains.js +70 -0
  7. package/dist/packages/core/src/config/labels.d.ts +22 -0
  8. package/dist/packages/core/src/config/labels.js +21 -0
  9. package/dist/packages/core/src/config/status.d.ts +51 -0
  10. package/dist/packages/core/src/config/status.js +47 -0
  11. package/dist/packages/core/src/consolidate.d.ts +170 -0
  12. package/dist/packages/core/src/consolidate.js +311 -0
  13. package/dist/packages/core/src/context.d.ts +2 -0
  14. package/dist/packages/core/src/context.js +48 -17
  15. package/dist/packages/core/src/dimension-text.d.ts +6 -0
  16. package/dist/packages/core/src/dimension-text.js +24 -0
  17. package/dist/packages/core/src/dimensions.d.ts +28 -0
  18. package/dist/packages/core/src/dimensions.js +53 -0
  19. package/dist/packages/core/src/extract.d.ts +140 -0
  20. package/dist/packages/core/src/extract.js +611 -0
  21. package/dist/packages/core/src/index.d.ts +362 -10
  22. package/dist/packages/core/src/index.js +983 -127
  23. package/dist/packages/core/src/inject-text.d.ts +16 -0
  24. package/dist/packages/core/src/inject-text.js +24 -0
  25. package/dist/packages/core/src/profile-prompt.d.ts +18 -0
  26. package/dist/packages/core/src/profile-prompt.js +103 -0
  27. package/dist/packages/core/src/prompts.d.ts +122 -0
  28. package/dist/packages/core/src/prompts.generated.d.ts +9 -0
  29. package/dist/packages/core/src/prompts.generated.js +9 -0
  30. package/dist/packages/core/src/prompts.js +293 -0
  31. package/dist/packages/core/src/reflect.d.ts +7 -0
  32. package/dist/packages/core/src/reflect.js +25 -27
  33. package/dist/packages/core/src/retrieval.d.ts +39 -0
  34. package/dist/packages/core/src/retrieval.js +105 -0
  35. package/dist/packages/core/src/roles.d.ts +18 -0
  36. package/dist/packages/core/src/roles.js +17 -0
  37. package/dist/packages/core/src/rules.d.ts +69 -0
  38. package/dist/packages/core/src/rules.js +152 -0
  39. package/dist/packages/core/src/sqlite.d.ts +26 -5
  40. package/dist/packages/core/src/sqlite.js +944 -33
  41. package/dist/packages/core/src/store.d.ts +92 -3
  42. package/dist/packages/core/src/types.d.ts +442 -1
  43. package/dist/packages/core/src/types.js +1 -0
  44. package/package.json +5 -3
@@ -9,18 +9,67 @@ export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError
9
9
  export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.js';
10
10
  export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule, detectCorrection, isQuestion, } from './reflect.js';
11
11
  export { rank, scoreMemory, tokenize, relevance, bm25, buildCorpusStats } from './scoring.js';
12
+ export { parseThreadLine } from './extract.js';
13
+ export { renderConsolidationPrompt, renderVerificationPrompt, renderRelationPrompt, renderRuleClassifyPrompt, renderProfileMergePrompt, renderExtractionPrompt, } from './prompts.js';
14
+ export { gateConclusion, conclusionSlots, normalizeStatement, parseConclusionDraft, parseVerificationDraft, parseRelationDraft, readVerification, parseProfileMerge, groupProfileCandidates, isDerivedProfileLine, PROFILE_DIMENSIONS, } from './consolidate.js';
15
+ export { fires, parseRuleClassifications, parseTrigger, selectRules } from './rules.js';
16
+ export { planRetrieval } from './retrieval.js';
12
17
  export * from './affect/index.js';
18
+ export { escapeBlockText, escapeAttr } from './inject-text.js';
13
19
  export { measureStyle } from './style/measure.js';
14
20
  export * from './storage/config.js';
15
21
  export * from './storage/merge.js';
16
22
  export * from './storage/steering.js';
17
23
  export * from './storage/transcript.js';
24
+ import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
25
+ import { HOLDER_WORDS } from './config/domains.js';
26
+ import { isActive, isRetracted, roleOf } from './roles.js';
27
+ import { parseTrigger } from './rules.js';
28
+ import { planRetrieval } from './retrieval.js';
29
+ import { gateConclusion, conclusionSlots, isDerivedProfileLine, PROFILE_DIMENSIONS } from './consolidate.js';
18
30
  import { buildContext } from './context.js';
19
31
  import { reflect, ruleExtract, nextEmotion } from './reflect.js';
20
32
  import { rank, tokenize } from './scoring.js';
21
33
  import { analyzeAffect, emptyAffect, lexiconAnalyzer } from './affect/analyze.js';
22
- import { gateDrafts, llmExtractTurn } from './reflect.js';
34
+ import { extractByRules, extractWithModel } from './extract.js';
23
35
  export { gateDrafts } from './reflect.js';
36
+ /** The fact type a legacy MemoryType maps to. One vocabulary at rest: type_name. */
37
+ const MEMORY_TYPE_TO_FACT = {
38
+ semantic: '事实',
39
+ episodic: '事件',
40
+ strategy: '教训',
41
+ cognition: '原则',
42
+ relationship: '关系',
43
+ trait: '性格',
44
+ fact: '事实',
45
+ };
46
+ /** Longest statement accepted from a host; matches the extractor's ceiling. */
47
+ const MAX_FACT_CHARS = 200;
48
+ /**
49
+ * Read a fact as the shape `recall()` has always returned.
50
+ *
51
+ * Callers (the adapter's `soul_recall` tool) only read id/type/content/score, but the
52
+ * type must stay honest: return the fact's own type name rather than inventing a
53
+ * MemoryType it never had.
54
+ */
55
+ function factAsMemory(fact) {
56
+ return {
57
+ id: fact.id,
58
+ soulId: fact.soulId,
59
+ userId: fact.userId,
60
+ type: 'fact',
61
+ content: fact.statement,
62
+ importance: Math.round(fact.confidence * 10),
63
+ vividness: 0.8,
64
+ recallCount: fact.recallCount ?? 0,
65
+ status: 'active',
66
+ visibility: 'private',
67
+ keywords: tokenize(fact.statement),
68
+ createdAt: fact.createdAt,
69
+ lastRecalledAt: fact.lastRecalledAt,
70
+ updatedAt: fact.createdAt,
71
+ };
72
+ }
24
73
  export class Soul {
25
74
  store;
26
75
  soulId;
@@ -53,29 +102,29 @@ export class Soul {
53
102
  }
54
103
  /** Verb 1: session start. */
55
104
  context(request = {}) {
105
+ // Spread, not a field list: an explicit list silently dropped every option
106
+ // added later (includeAnchors stayed dead for a whole release).
56
107
  return buildContext(this.store, {
108
+ ...request,
57
109
  soulId: this.soulId,
58
110
  userId: this.userId,
59
- query: request.query,
60
- maxChars: request.maxChars,
61
- track: request.track,
62
- types: request.types,
63
- minImportance: request.minImportance,
64
- maxItems: request.maxItems,
65
111
  });
66
112
  }
67
113
  /**
68
114
  * Audit view: every non-deleted row, including superseded and expired ones.
69
115
  * Prompt builders use `context`, which filters both out.
70
116
  */
117
+ /**
118
+ * The recent holdings, as the shape hosts already consume.
119
+ *
120
+ * Reads facts: the legacy table is inert after the migration, and a list that kept
121
+ * reading it would look empty while the system clearly remembers things.
122
+ */
71
123
  list(limit = 50) {
72
- return this.store.query({
73
- soulId: this.soulId,
74
- userId: this.userId,
75
- status: ['active', 'superseded'],
76
- includeExpired: true,
77
- limit,
78
- });
124
+ return this.store
125
+ .facts({ soulId: this.soulId, userId: this.userId, limit })
126
+ .filter((fact) => isActive(fact))
127
+ .map(factAsMemory);
79
128
  }
80
129
  /** Tombstone one memory so it never reaches a prompt again. */
81
130
  forget(id) {
@@ -107,6 +156,33 @@ export class Soul {
107
156
  corrections(limit = 20) {
108
157
  return this.store.recentCorrections(this.soulId, this.userId, limit);
109
158
  }
159
+ /**
160
+ * Was this turn one the user corrected?
161
+ *
162
+ * A correction is the highest-signal moment a lesson has: being told you were
163
+ * wrong is the consequence the experience comes with (brief 0023, T1). The link
164
+ * is the user's own words — corrections do not carry a turn id, and inventing a
165
+ * time window would misattribute a nearby turn.
166
+ */
167
+ wasCorrectedTurn(turnId) {
168
+ const turn = this.store.turn(turnId);
169
+ const text = (turn?.userText ?? '').trim();
170
+ if (!text)
171
+ return false;
172
+ return this.store
173
+ .recentCorrections(this.soulId, this.userId, 50)
174
+ .some((correction) => (correction.userText ?? '').trim() === text);
175
+ }
176
+ /**
177
+ * The experiences of one turn that were flagged as lessons, and nothing else.
178
+ *
179
+ * Promotion reads the flag, never the statement: whether a sentence is "about how
180
+ * to work" is the semantic call the extractor already made, and re-deriving it in
181
+ * code would be the guess ADR 0027 forbids.
182
+ */
183
+ promotionCandidates(turnId) {
184
+ return this.store.factsByTurns([turnId]).filter((fact) => fact.slots.some((slot) => slot.slot === '原则候选' && slot.value.kind === 'text' && slot.value.value === '是'));
185
+ }
110
186
  /**
111
187
  * Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
112
188
  * ignored by the store, so this returns nothing — there is no id worth handing
@@ -114,8 +190,11 @@ export class Soul {
114
190
  */
115
191
  recordTurnPair(input) {
116
192
  const at = input.at ?? Date.now();
193
+ // The generated id is the row id in `turns`; callers must use it for
194
+ // facts.source_turn_id, otherwise a hook cannot expand back to its turn.
195
+ const id = `tp_${at.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
117
196
  this.store.putTurnPair({
118
- id: `tp_${at.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
197
+ id,
119
198
  soulId: this.soulId,
120
199
  userId: this.userId,
121
200
  sessionId: input.sessionId,
@@ -125,7 +204,19 @@ export class Soul {
125
204
  assistantText: input.assistantText,
126
205
  metadata: input.metadata ?? {},
127
206
  createdAt: at,
128
- });
207
+ }, input.steps);
208
+ // A repeat is dropped by the unique (session, turn) index, so the generated id
209
+ // may be a phantom. Return the id that is actually stored: the caller enqueues
210
+ // extraction with it, and a phantom id made the queue drop the turn as missing.
211
+ return this.store.turnByTurnNo(input.sessionId, input.turn)?.id ?? id;
212
+ }
213
+ /**
214
+ * One turn by id, for adapters that only kept the id (extraction queue,
215
+ * handoff). Returns null when the row is gone, so callers can drop the item
216
+ * instead of retrying forever.
217
+ */
218
+ turn(id) {
219
+ return this.store.turn(id);
129
220
  }
130
221
  /** Recall exchanges, scored against `query` when it is non-empty. */
131
222
  recallTurnPairs(query, limit = 5) {
@@ -139,87 +230,806 @@ export class Soul {
139
230
  countTurnPairs() {
140
231
  return this.store.countTurnPairs(this.soulId, this.userId);
141
232
  }
142
- /** Verb 3: on demand. */
143
- recall(query, limit = 5) {
144
- const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
145
- const ranked = rank(memories, query, Date.now()).slice(0, limit);
146
- this.store.touch(ranked.map((r) => r.memory.id));
147
- return ranked;
233
+ /**
234
+ * Every turn that matches, not the best few: for "how many times" and "what
235
+ * did we decide overall" the count matters more than the ranking, and the
236
+ * model does the counting (ADR 0027). `truncated` tells the caller the answer
237
+ * would be incomplete.
238
+ */
239
+ /* ---------------- the model proper (spec/model.v0.md) ---------------- */
240
+ /**
241
+ * Turn text → drafts.
242
+ *
243
+ * Extraction is the model's job (ADR 0029: Generative Agents, Mem0 and
244
+ * Graphiti all extract with an LLM and keep code for validation and storage).
245
+ * The rules are a floor, not a ceiling: they matter when no model is
246
+ * configured and they cross-check the answer when one is. A model answer with
247
+ * no usable draft falls back to the rules instead of losing the turn.
248
+ */
249
+ async extract(text, options = {}) {
250
+ const at = options.at ?? Date.now();
251
+ const byRules = extractByRules(text, at);
252
+ // No length gate here: "做" and "重来" are one and two characters and are
253
+ // real decisions. The host decides what is worth a model call; core only
254
+ // reports which path it took.
255
+ if (options.mode === 'rules')
256
+ return { ...byRules, reason: 'rules-mode' };
257
+ if (!options.llm)
258
+ return { ...byRules, reason: 'no-model' };
259
+ // The model has to know what is already stored, or it cannot reuse a name
260
+ // and cannot tell that new information contradicts an old fact.
261
+ const context = options.context ?? this.extractionContext();
262
+ // A parse failure is unreadable without the text the model actually sent:
263
+ // "model-failed" alone hid a 54-character reply for an hour today.
264
+ const wrapped = this.onLlmRaw
265
+ ? async (prompt) => {
266
+ const raw = await options.llm(prompt);
267
+ this.onLlmRaw(raw);
268
+ return raw;
269
+ }
270
+ : options.llm;
271
+ const result = await extractWithModel(text, wrapped, at, context, options.assistantText);
272
+ return result.draft ?? { ...byRules, reason: `model-${result.error ? 'failed' : 'empty'}` };
148
273
  }
149
- /** Pure analysis, no storage. Async because the analyzer may call a model. */
150
- async analyze(text) {
151
- return this.analyzeTurn(text);
274
+ /** The slice of the store the extractor is allowed to know about. */
275
+ extractionContext(limit = 20, options = {}) {
276
+ const entities = this.store
277
+ .findEntities({ soulId: this.soulId, userId: this.userId, limit })
278
+ .map((e) => ({
279
+ id: e.id,
280
+ name: e.name,
281
+ aliases: e.aliases,
282
+ relation: typeof e.attrs.relation === 'string' ? e.attrs.relation : undefined,
283
+ }));
284
+ const facts = this.store
285
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 12 })
286
+ .map((f) => ({ id: f.id, statement: f.statement, atText: f.atText }));
287
+ // Candidate threads, most recently used first. "How many turns ago" is what
288
+ // lets the model tell "just now" from "a while back" without counting. Each
289
+ // candidate also carries its last lines: a label cannot answer "does this turn
290
+ // continue that conversation", and continuing is now the default (ADR 0034).
291
+ const threads = this.store.openThreads(this.soulId, this.userId, 5).map((thread) => {
292
+ const turns = this.store.threadTurns(thread.id).slice(-2);
293
+ const recent = turns.flatMap((turn) => {
294
+ const lines = [];
295
+ if (turn.userText.trim())
296
+ lines.push({ role: 'user', text: turn.userText });
297
+ if (turn.assistantText.trim())
298
+ lines.push({ role: 'assistant', text: turn.assistantText });
299
+ return lines;
300
+ });
301
+ return {
302
+ id: thread.id,
303
+ topic: thread.topic,
304
+ ago: options.turnNo !== undefined && thread.lastTurnNo !== undefined
305
+ ? Math.max(0, options.turnNo - thread.lastTurnNo)
306
+ : undefined,
307
+ recent: recent.slice(-3),
308
+ };
309
+ });
310
+ // The previous user line is what "继续 / 那个 / 她" refers to. Extraction is per
311
+ // turn; without it the model has nothing to bind to.
312
+ const previousUserText = options.sessionId !== undefined && options.turnNo !== undefined
313
+ ? (this.store.previousTurnText(options.sessionId, options.turnNo) ?? undefined)
314
+ : undefined;
315
+ return { entities, facts, threads, previousUserText };
152
316
  }
153
317
  /**
154
- * Verb 2: after each turn.
318
+ * Put a turn into a thread.
155
319
  *
156
- * The analyzer may be async (a model); any failure degrades to the lexicon
157
- * analyzer. Emotion is computed from the turn only — never from affect.
320
+ * Default is continuity (ADR 0034): a named thread joins when it exists and is open;
321
+ * an unreadable answer, a hallucinated id, or no answer at all joins the previous
322
+ * turn's thread instead of opening a new one — fragmentation is what starves
323
+ * consolidation, and threads can be split later while a lost topic cannot.
158
324
  */
159
- async observe(turn) {
160
- const now = turn.at ?? Date.now();
161
- const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
162
- // Rules first: they are precise and cheap. Only when they find nothing does
163
- // the optional model get a chance, so it can add recall without overriding
164
- // a rule that already fired.
165
- let drafts = ruleExtract([turn]);
166
- let extractedBy = drafts.length > 0 ? 'rules' : 'none';
167
- // A model that says "nothing to keep" and a model that answers garbage used
168
- // to be indistinguishable; keep them apart for measurement.
169
- let llmOutcome = this.llm
170
- ? 'skipped-rules'
171
- : 'off';
172
- if (drafts.length === 0 && turn.role === 'user' && this.llm) {
173
- if (turn.content.length <= this.llmMaxChars) {
174
- try {
175
- const draft = await llmExtractTurn(turn.content, this.llm, this.onLlmRaw);
176
- if (draft) {
177
- drafts = [draft];
178
- extractedBy = 'llm';
179
- llmOutcome = 'stored';
180
- }
181
- else {
182
- llmOutcome = 'null';
183
- }
325
+ applyThread(attribution, spec) {
326
+ const join = (id, reason, nextTopic) => {
327
+ const thread = this.store.thread(id);
328
+ if (!thread || thread.status !== 'open')
329
+ return null;
330
+ this.store.attachTurn(spec.turnId, thread.id);
331
+ this.store.touchThread(thread.id, spec.turnNo);
332
+ // A continued thread may have moved on within itself: keep the label honest.
333
+ const topic = nextTopic?.trim();
334
+ const updated = Boolean(topic) && topic !== thread.topic;
335
+ if (updated)
336
+ this.store.updateThreadTopic(thread.id, topic);
337
+ return {
338
+ threadId: thread.id,
339
+ created: false,
340
+ topic: updated ? topic : thread.topic,
341
+ joinReason: reason,
342
+ topicUpdated: updated,
343
+ };
344
+ };
345
+ if (attribution?.kind === 'continue') {
346
+ const existing = join(attribution.id, 'continue', attribution.topic);
347
+ if (existing)
348
+ return existing;
349
+ // A named thread that does not exist (or is closed) is not evidence for a new
350
+ // topic — fall through to the previous thread, then to a new one.
351
+ }
352
+ if (attribution?.kind === 'new') {
353
+ return this.openThreadFor(spec, 'explicit-new', attribution.topic);
354
+ }
355
+ if (spec.previousThreadId) {
356
+ const previous = join(spec.previousThreadId, 'fallback-previous');
357
+ if (previous)
358
+ return previous;
359
+ }
360
+ // `attribution === undefined` covers "无" and unparseable output.
361
+ return this.openThreadFor(spec, attribution?.kind === 'continue' ? 'fallback-unknown-id' : 'no-context');
362
+ }
363
+ openThreadFor(spec, joinReason, topicFromModel) {
364
+ const topic = topicFromModel ?? spec.fallbackTopic ?? '未命名话题';
365
+ const thread = this.store.openThread({
366
+ soulId: this.soulId,
367
+ userId: this.userId,
368
+ sessionId: spec.sessionId,
369
+ // Whole topic: a label cut at 20 characters loses the end, which is where the
370
+ // qualifier usually sits. The prompt asks for one short sentence; storage does
371
+ // not cut one (ADR 0034 keeps truncation out of evidence).
372
+ topic,
373
+ openedAt: spec.at,
374
+ turnNo: spec.turnNo,
375
+ });
376
+ this.store.attachTurn(spec.turnId, thread.id);
377
+ return { threadId: thread.id, created: true, topic: thread.topic, joinReason, topicUpdated: false };
378
+ }
379
+ /**
380
+ * Drafts → database.
381
+ *
382
+ * The gate is a labeller, not a filter. A sentence that parses is kept: an
383
+ * undeclared entity in a slot is kept as text (no entity is created), an unknown
384
+ * holder falls back to the world layer, and only an unreadable statement or an
385
+ * exact duplicate is refused. Everything degraded or refused is reported, so the
386
+ * loss is visible instead of silent.
387
+ */
388
+ commitDrafts(draft, options = {}) {
389
+ const entities = new Map();
390
+ const skipped = [];
391
+ const droppedSlots = [];
392
+ const holderFallbacks = [];
393
+ const at = options.at ?? Date.now();
394
+ // One entity per name, whatever its declared type: the same 核桃 appearing as
395
+ // both person and thing splits every recall that mentions it. Existing rows
396
+ // win, so a wrongly typed first sighting is reused rather than duplicated.
397
+ // Aliases are the same entity: without them "阿林" reads as undeclared and the
398
+ // slot degrades to text even though the person is known.
399
+ const byName = new Map();
400
+ for (const known of this.store.findEntities({ soulId: this.soulId, userId: this.userId, limit: 500 })) {
401
+ if (!byName.has(known.name))
402
+ byName.set(known.name, known);
403
+ for (const alias of known.aliases ?? []) {
404
+ if (alias && !byName.has(alias))
405
+ byName.set(alias, known);
406
+ }
407
+ }
408
+ const declared = new Set(draft.entities.map((entity) => entity.name));
409
+ // No default type: choosing person/thing is a meaning decision, and a default here
410
+ // would let the code make it silently (ADR 0027, health-check §12). Every caller
411
+ // passes the type the model declared.
412
+ const ensureEntity = (name, type) => {
413
+ const existing = byName.get(name);
414
+ if (existing)
415
+ return existing;
416
+ const key = `${type}:${name}`;
417
+ const pending = entities.get(key);
418
+ if (pending)
419
+ return pending;
420
+ const written = this.store.putEntity({
421
+ soulId: this.soulId,
422
+ userId: this.userId,
423
+ type,
424
+ name,
425
+ seenAt: at,
426
+ attrs: draft.entities.find((e) => e.name === name)?.attrs,
427
+ aliases: draft.entities.find((e) => e.name === name)?.aliases,
428
+ });
429
+ entities.set(key, written);
430
+ byName.set(name, written);
431
+ return written;
432
+ };
433
+ for (const draftEntity of draft.entities)
434
+ ensureEntity(draftEntity.name, draftEntity.type);
435
+ const facts = [];
436
+ // Declared before writeFact: a fact whose time is unknown opens a gap inside it.
437
+ const gaps = [];
438
+ const recent = this.store.facts({ soulId: this.soulId, userId: this.userId, limit: 50 });
439
+ /** One gate for every write path: legacy facts and operation payloads share it. */
440
+ const writeFact = (factDraft) => {
441
+ const statement = factDraft.statement.trim();
442
+ if (!statement) {
443
+ skipped.push({ what: 'fact', reason: 'empty statement' });
444
+ return null;
445
+ }
446
+ // Every readable sentence is kept. The gate used to reject a fact when one of
447
+ // its slots named an undeclared entity (40 of 45 refusals in a live trace) or
448
+ // when it had no slot and no time (5 more) — one bad slot deleted the sentence.
449
+ //
450
+ // A slot names an entity only when that name was declared or is already known.
451
+ // Otherwise the words are kept as text: the sentence survives, the store does
452
+ // not fill up with "仅在电话里说" as a person, and nothing is invented.
453
+ const slots = [];
454
+ for (const slot of factDraft.slots) {
455
+ if (slot.value.kind !== 'entity') {
456
+ slots.push({ slot: slot.slot, value: slot.value });
457
+ continue;
184
458
  }
185
- catch {
186
- // The model is an enhancement; a transport or parse failure leaves the
187
- // rule result (nothing) in place.
188
- llmOutcome = 'failed';
459
+ const name = slot.value.entityName.trim();
460
+ // Only a declared or already-known name is a reference; the declaration carries
461
+ // the type, so the code never has to guess one.
462
+ const known = byName.get(name);
463
+ const fromDraft = declared.has(name) ? draft.entities.find((entity) => entity.name === name) : undefined;
464
+ const type = known?.type ?? fromDraft?.type;
465
+ if (fromDraft || known) {
466
+ const entity = known ?? ensureEntity(name, type);
467
+ slots.push({ slot: slot.slot, value: { kind: 'entity', entityId: entity.id } });
468
+ continue;
189
469
  }
470
+ slots.push({ slot: slot.slot, value: { kind: 'text', value: name } });
471
+ droppedSlots.push({ fact: statement, slot: slot.slot, value: name });
190
472
  }
191
- else {
192
- llmOutcome = 'skipped-long';
473
+ // Where the fact holds (ADR 0032): `world`, `self`, or a named mind. The holder is
474
+ // a reference to an entity that already exists (declared entities are created
475
+ // above), never a new entity created from a holder string.
476
+ const holderName = factDraft.holder?.trim() || HOLDER_WORDS[0];
477
+ let holder = HOLDER_WORDS[0];
478
+ if (holderName !== HOLDER_WORDS[0]) {
479
+ if (holderName === HOLDER_WORDS[1]) {
480
+ holder = HOLDER_WORDS[1];
481
+ }
482
+ else {
483
+ const holderEntity = byName.get(holderName);
484
+ if (holderEntity)
485
+ holder = holderEntity.id;
486
+ else
487
+ holderFallbacks.push({ fact: statement, holder: holderName });
488
+ }
489
+ }
490
+ const duplicated = recent.some((f) => f.statement === statement &&
491
+ (f.atText ?? '') === (factDraft.atText ?? '') &&
492
+ Math.abs((f.at ?? f.createdAt) - (factDraft.at ?? at)) < 86400000);
493
+ if (duplicated) {
494
+ skipped.push({ what: statement, reason: 'duplicate' });
495
+ return null;
496
+ }
497
+ // The promotion rules read this slot; they must not re-read the statement to
498
+ // guess whether it was a lesson, because that is the semantic call the model
499
+ // already made.
500
+ if (factDraft.principle)
501
+ slots.push({ slot: '原则候选', value: { kind: 'text', value: '是' } });
502
+ // ADR 0035: a fact from my side of the exchange is `inferred`, and inferred
503
+ // claims must carry their evidence. The turn id is the evidence available here —
504
+ // claims that need a tool or test result are kept out by the prompt instead.
505
+ if (factDraft.source === 'inferred' && options.turnId && !slots.some((slot) => slot.slot === '依据')) {
506
+ slots.push({ slot: '依据', value: { kind: 'text', value: options.turnId } });
507
+ }
508
+ const written = this.store.putFact({
509
+ soulId: this.soulId,
510
+ userId: this.userId,
511
+ statement,
512
+ holder,
513
+ typeName: factDraft.typeName,
514
+ at: factDraft.at,
515
+ atText: factDraft.atText,
516
+ atPrecision: factDraft.atPrecision,
517
+ source: factDraft.source,
518
+ confidence: factDraft.confidence,
519
+ sourceTurnId: options.turnId,
520
+ slots,
521
+ });
522
+ facts.push(written);
523
+ // Known to have happened, unknown when: keep the memory and turn the missing
524
+ // date into something to ask about later, instead of inventing one.
525
+ if (holder === 'world' && written.atPrecision === 'unknown') {
526
+ const subject = slots.find((slot) => slot.value.kind === 'entity');
527
+ gaps.push(this.store.putGap({
528
+ soulId: this.soulId,
529
+ userId: this.userId,
530
+ aboutEntityId: subject?.value.kind === 'entity' ? subject.value.entityId : undefined,
531
+ aboutFactId: written.id,
532
+ // Whole statement: the gap asks "when did this happen", so the sentence it
533
+ // refers to cannot be a prefix of itself.
534
+ topic: `日期不明:${statement}`,
535
+ tension: 0.3,
536
+ openedAt: at,
537
+ }));
538
+ }
539
+ return written;
540
+ };
541
+ // Operations carry the merge decision (Mem0's ADD/UPDATE/DELETE); plain facts are
542
+ // the legacy shape and every one of them is an add.
543
+ // `validateDraft` always returns an operations array, so `??` alone left it empty
544
+ // and every legacy fact was written nowhere — silently, with no skipped entry.
545
+ const operations = draft.operations && draft.operations.length > 0
546
+ ? draft.operations
547
+ : draft.facts.map((fact) => ({ op: 'add', fact }));
548
+ for (const operation of operations) {
549
+ if (operation.op === 'noop')
550
+ continue;
551
+ const written = writeFact(operation.fact);
552
+ if (!written)
553
+ continue;
554
+ if (operation.op === 'supersede' || operation.op === 'update') {
555
+ // The model may name any id; only a row we actually hold may be retired. A
556
+ // missing target degrades to a plain add and is reported — the sentence is never
557
+ // lost, and "the fact changed" is never claimed without a real predecessor.
558
+ const target = operation.targetId ? this.store.fact(operation.targetId) : null;
559
+ const sameScope = target && target.soulId === this.soulId && target.userId === this.userId;
560
+ if (target && sameScope && target.status !== 'superseded') {
561
+ this.store.supersedeFact(target.id, written.id);
562
+ }
563
+ else {
564
+ skipped.push({
565
+ what: written.statement,
566
+ reason: target ? `supersede-target-${target.status}` : `unknown-supersede-target: ${operation.targetId ?? '(missing)'}`,
567
+ });
568
+ }
193
569
  }
194
570
  }
195
- // Deterministic write gates: shape -> semantics -> per-turn budget. Whatever
196
- // they refuse is reported, so written: 0 always has a reason.
197
- const gated = gateDrafts(drafts);
198
- // observe runs every turn; repeating a sentence must not create a second row.
199
- const seen = new Set(this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 }).map((m) => m.content.trim()));
200
- let written = 0;
201
- for (const draft of gated.accepted) {
202
- if (seen.has(draft.content.trim()))
571
+ for (const gapDraft of draft.gaps) {
572
+ // A gap about a known entity only; no type guessing, and no entity minted from a
573
+ // gap's wording (that is how "仅在电话里说" became a person).
574
+ const about = gapDraft.aboutEntityName ? byName.get(gapDraft.aboutEntityName) : undefined;
575
+ gaps.push(this.store.putGap({
576
+ soulId: this.soulId,
577
+ userId: this.userId,
578
+ aboutEntityId: about?.id,
579
+ aboutFactId: facts.length === 1 ? facts[0].id : undefined,
580
+ topic: gapDraft.topic,
581
+ tension: gapDraft.tension,
582
+ openedAt: at,
583
+ }));
584
+ }
585
+ return { entities: [...entities.values()], facts, gaps, skipped, droppedSlots, holderFallbacks };
586
+ }
587
+ /** Remember a person/thing/place; calling again just refreshes "last mentioned". */
588
+ rememberEntity(name,
589
+ // No default type: person/thing/place is a meaning decision, and a default would let
590
+ // every caller silently decide "person" (ADR 0027, health-check §9.5).
591
+ type, options = {}) {
592
+ return this.store.putEntity({
593
+ soulId: this.soulId,
594
+ userId: this.userId,
595
+ type,
596
+ name,
597
+ aliases: options.aliases,
598
+ attrs: options.attrs,
599
+ seenAt: options.seenAt,
600
+ });
601
+ }
602
+ findEntities(name, limit = 20) {
603
+ return this.store.findEntities({ soulId: this.soulId, userId: this.userId, name, limit });
604
+ }
605
+ /** Write one connection between things. */
606
+ recordFact(spec) {
607
+ return this.store.putFact({ ...spec, soulId: this.soulId, userId: this.userId });
608
+ }
609
+ /**
610
+ * Store one conclusion if the deterministic gate accepts it.
611
+ *
612
+ * A conclusion is not a new kind of record: it is a fact whose type is 结论,
613
+ * held by the soul itself, with one `fact` slot per citation. That way recall,
614
+ * export and supersede keep working, and the evidence can be followed back to
615
+ * the observation that produced it.
616
+ */
617
+ recordConclusion(draft, options) {
618
+ const allowed = new Set(options.allowed);
619
+ // A restatement is merged, not written again: the held conclusion keeps its
620
+ // wording and gains the evidence, so it gets stronger instead of duplicated.
621
+ // Only evidence from this batch counts — a merge must be backed the same way
622
+ // a new conclusion would be.
623
+ if (options.mergeInto) {
624
+ const evidence = draft.evidence.filter((id) => allowed.has(id));
625
+ if (evidence.length >= 2) {
626
+ const merge = this.mergeEvidence(options.mergeInto, evidence);
627
+ if (merge.ok)
628
+ return { ok: true, merged: options.mergeInto };
629
+ // A merge target that moved on is not an error; fall through and write the
630
+ // conclusion normally rather than losing it.
631
+ }
632
+ }
633
+ const old = draft.supersedes ? this.store.fact(draft.supersedes) : null;
634
+ const gate = gateConclusion({ draft, allowed, existing: old });
635
+ if (!gate.ok)
636
+ return { ok: false, reason: gate.reason };
637
+ const fact = this.store.putFact({
638
+ soulId: this.soulId,
639
+ userId: this.userId,
640
+ statement: gate.statement,
641
+ typeName: '结论',
642
+ holder: 'self',
643
+ confidence: gate.confidence,
644
+ slots: conclusionSlots(gate.evidence, this.entitiesOfFacts(gate.evidence)),
645
+ });
646
+ // Superseding an unknown row would silently claim a revision that never
647
+ // happened; the gate already checked the target is a conclusion we hold.
648
+ let superseded;
649
+ if (old?.typeName === '结论') {
650
+ this.store.supersedeFact(old.id, fact.id);
651
+ superseded = old.id;
652
+ }
653
+ return { ok: true, fact, superseded };
654
+ }
655
+ /**
656
+ * The rules we hold (ADR 0033): facts whose type is 规则 and whose 触发 slot parses.
657
+ *
658
+ * A rule without a usable trigger is skipped, not injected — an always-on rule is
659
+ * wallpaper, and wallpaper is how a lesson gets ignored.
660
+ */
661
+ rules() {
662
+ const out = [];
663
+ for (const fact of this.store.facts({ soulId: this.soulId, userId: this.userId, limit: 500 })) {
664
+ if (roleOf(fact) !== 'rule' || !isActive(fact))
665
+ continue;
666
+ const triggerSlot = fact.slots.find((slot) => slot.slot === '触发' && slot.value.kind === 'text');
667
+ const trigger = parseTrigger(triggerSlot?.value.kind === 'text' ? triggerSlot.value.value : '');
668
+ if (!trigger)
203
669
  continue;
204
- seen.add(draft.content.trim());
205
- written += 1;
206
- this.store.put({
207
- id: `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
670
+ out.push({
671
+ id: fact.id,
672
+ statement: fact.statement,
673
+ trigger,
674
+ confidence: fact.confidence,
675
+ });
676
+ }
677
+ return out;
678
+ }
679
+ /**
680
+ * Apply a rule classification (ADR 0033): retag as 规则 and store its trigger.
681
+ *
682
+ * The trigger is what makes the rule injectable later; a rule without one would
683
+ * have to be always-on, which is exactly what this split exists to avoid.
684
+ */
685
+ markRule(factId, classification) {
686
+ this.store.setFactTypeName(factId, '规则');
687
+ const slots = [
688
+ { slot: '触发', value: { kind: 'text', value: classification.trigger } },
689
+ ];
690
+ if (classification.action)
691
+ slots.push({ slot: '动作', value: { kind: 'text', value: classification.action } });
692
+ this.store.addSlots(factId, slots);
693
+ }
694
+ /** Apply a profile classification: it is about the person, not a triggered rule. */
695
+ markProfile(factId, options = {}) {
696
+ this.store.setFactTypeName(factId, '画像');
697
+ // The dimension is written here because this is the moment the model decided
698
+ // which lens the judgment belongs to. The merge step cuts its batches along
699
+ // this value later, so a missing dimension is a judgment that can never be
700
+ // merged — visible in the merge log as 未标注, not silently guessed.
701
+ const dimension = options.dimension;
702
+ if (dimension && PROFILE_DIMENSIONS.includes(dimension)) {
703
+ this.store.addSlots(factId, [{ slot: '维度', value: { kind: 'text', value: dimension } }]);
704
+ }
705
+ }
706
+ /**
707
+ * Park a lesson that could not be classified.
708
+ *
709
+ * Neither bucket is safe for it: as a rule it would fire every turn, and as a
710
+ * profile line it would put an imperative into the description of a person. It
711
+ * goes to 待修, which nothing injects, and shows up in a review count.
712
+ */
713
+ markUnclassified(factId) {
714
+ this.store.setFactTypeName(factId, '待修');
715
+ }
716
+ /** The judgments about the person that are waiting to be merged. */
717
+ profileItems(limit = 60) {
718
+ return this.store
719
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 500 })
720
+ .filter((fact) => roleOf(fact) === 'profile' &&
721
+ isActive(fact) &&
722
+ // A line that cites sources is derived: merging it again compresses a
723
+ // compression, and the second round is thinner than the first.
724
+ !isDerivedProfileLine(fact))
725
+ .slice(0, limit);
726
+ }
727
+ /**
728
+ * Replace a pile of judgments with the merged ones.
729
+ *
730
+ * Each merged line becomes a 画像 fact citing its sources, and every source it
731
+ * covers is superseded — not deleted, so the audit trail survives the compression.
732
+ */
733
+ applyProfileMerge(lines) {
734
+ const written = [];
735
+ for (const line of lines) {
736
+ const fact = this.store.putFact({
208
737
  soulId: this.soulId,
209
738
  userId: this.userId,
210
- type: draft.type,
211
- content: draft.content,
212
- importance: draft.importance,
213
- vividness: 0.8,
214
- recallCount: 0,
215
- status: 'active',
216
- visibility: 'private',
217
- keywords: tokenize(draft.content),
218
- createdAt: now,
219
- updatedAt: now,
220
- validFrom: now,
739
+ statement: line.statement,
740
+ typeName: '画像',
741
+ holder: 'self',
742
+ // More sources means more observations behind the line, so it ranks first.
743
+ confidence: Math.min(0.95, 0.6 + 0.05 * line.sources.length),
744
+ slots: [
745
+ ...line.sources.map((id) => ({ slot: '来源', value: { kind: 'fact', factId: id } })),
746
+ // A merged line is not an original: re-merging merged lines loses detail.
747
+ { slot: '合并', value: { kind: 'text', value: '1' } },
748
+ { slot: '维度', value: { kind: 'text', value: line.dimension } },
749
+ ],
221
750
  });
751
+ written.push(fact);
752
+ for (const sourceId of line.sources) {
753
+ const source = this.store.fact(sourceId);
754
+ if (source && isActive(source))
755
+ this.store.supersedeFact(sourceId, fact.id);
756
+ }
757
+ }
758
+ return written;
759
+ }
760
+ /** The merged profile, strongest first — what is injected every turn. */
761
+ profile(limit = 8) {
762
+ return this.store
763
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 500 })
764
+ .filter((fact) => roleOf(fact) === 'profile' && isActive(fact))
765
+ .sort((a, b) => b.confidence - a.confidence || b.createdAt - a.createdAt)
766
+ .slice(0, limit);
767
+ }
768
+ /** Which primitive this question needs (0020).
769
+ *
770
+ * The subject is resolved against the entities we hold, so "上次和 a 吃饭" can
771
+ * never be answered with the last closed topic.
772
+ */
773
+ planRetrieval(text) {
774
+ // Only people and places can be a question's subject. A concept entity like 情绪
775
+ // or a product like dsh matches ordinary technical messages, and routing to it
776
+ // suppresses the related-conversation block — which is how "把 package.json 改
777
+ // 一下" ended up asking about entity "a".
778
+ const names = this.store
779
+ .findEntities({ soulId: this.soulId, userId: this.userId, limit: 500 })
780
+ .filter((entity) => entity.type === 'person' || entity.type === 'place' || entity.type === 'self')
781
+ .map((entity) => entity.name);
782
+ return planRetrieval(text, names);
783
+ }
784
+ /** The entity timeline: what is known about one person/thing, newest first. */
785
+ latestAbout(entityName, options = {}) {
786
+ return this.store.latestAbout(this.soulId, this.userId, entityName, options);
787
+ }
788
+ /** Mark facts as surfaced; retrieval practice slows their decay (0019). */
789
+ recordRecalls(factIds, at = Date.now()) {
790
+ this.store.recordRecalls(factIds, at);
791
+ }
792
+ /**
793
+ * Close topics the conversation has moved past.
794
+ *
795
+ * Three signals, in order of strength: the host's archive list (the user said
796
+ * this session is done), turns elapsed inside the running session, and — only
797
+ * as a fallback — silence in another session.
798
+ */
799
+ closeIdleThreads(sessionId, options = {}) {
800
+ return this.store.closeIdleThreads(this.soulId, this.userId, sessionId, options.idleTurns ?? 6, options.idleMs ?? 24 * 60 * 60_000, options.archivedSessionIds ?? [], options.at ?? Date.now());
801
+ }
802
+ /** The thread the previous turn belonged to, for the attribution candidate list. */
803
+ threadBefore(sessionId, turnNo) {
804
+ return this.store.threadBefore(sessionId, turnNo);
805
+ }
806
+ /**
807
+ * Everything a closed thread needs for consolidation: the topic, the turns in
808
+ * order (as text), and the facts extracted from those turns.
809
+ *
810
+ * The turns are what makes this a chapter rather than a batch: A B A means the
811
+ * facts alone have lost the resume.
812
+ */
813
+ threadContext(threadId) {
814
+ const thread = this.store.thread(threadId);
815
+ if (!thread)
816
+ return null;
817
+ const turns = this.store.threadTurns(threadId);
818
+ const facts = this.store.factsByTurns(turns.map((turn) => turn.id));
819
+ return {
820
+ thread,
821
+ turns: turns.map((turn) => ({ turnNo: turn.turn, userText: turn.userText, assistantText: turn.assistantText })),
822
+ facts,
823
+ };
824
+ }
825
+ /**
826
+ * What "where were we" means: the last topic that actually finished, its
827
+ * conclusion, and the last couple of turns.
828
+ *
829
+ * A session boundary is not a topic boundary, so this is keyed on the closed
830
+ * thread, not on the previous session.
831
+ */
832
+ lastClosedThread() {
833
+ const thread = this.store.lastClosedThread(this.soulId, this.userId);
834
+ if (!thread)
835
+ return null;
836
+ const conclusion = thread.summaryFactId ? this.store.fact(thread.summaryFactId) : null;
837
+ const turns = this.store
838
+ .threadTurns(thread.id)
839
+ .slice(-2)
840
+ .map((turn) => ({ turnNo: turn.turn, userText: turn.userText, assistantText: turn.assistantText }));
841
+ return { thread, conclusion, turns };
842
+ }
843
+ /** The entities a set of facts mentions — the key a conclusion is recalled by. */
844
+ entitiesOfFacts(factIds) {
845
+ const ids = new Set();
846
+ for (const factId of factIds) {
847
+ const fact = this.store.fact(factId);
848
+ for (const slot of fact?.slots ?? []) {
849
+ if (slot.value.kind === 'entity')
850
+ ids.add(slot.value.entityId);
851
+ }
222
852
  }
853
+ return [...ids];
854
+ }
855
+ /**
856
+ * Conclusions worth comparing a new one against.
857
+ *
858
+ * Ordered by shared evidence first, then recency: two conclusions drawn from
859
+ * the same facts are the likeliest restatements, and the recency tail catches
860
+ * the ones whose evidence was reworded.
861
+ */
862
+ conclusionCandidates(evidence, limit = 8) {
863
+ const wanted = new Set(evidence);
864
+ const wantedEntities = new Set(this.entitiesOfFacts(evidence));
865
+ return this.conclusions(200)
866
+ .map((fact) => {
867
+ const ids = evidenceOf(fact);
868
+ const sharedEntities = fact.slots.filter((slot) => slot.slot === '关于' && slot.value.kind === 'entity' && wantedEntities.has(slot.value.entityId)).length;
869
+ // Shared evidence first, then the same person/thing: two conclusions about
870
+ // the same entity are the likeliest restatements even when their evidence
871
+ // happens to be disjoint.
872
+ const shared = ids.filter((id) => wanted.has(id)).length * 2 + sharedEntities;
873
+ return { fact, shared, evidence: ids.length };
874
+ })
875
+ .sort((a, b) => b.shared - a.shared)
876
+ .slice(0, limit)
877
+ .map((entry) => ({ id: entry.fact.id, statement: entry.fact.statement, evidence: entry.evidence }));
878
+ }
879
+ /**
880
+ * Fold new evidence into a held conclusion.
881
+ *
882
+ * The statement is not rewritten: keeping it makes the merge auditable, and a
883
+ * richer statement is the model's job at the next natural revision, not this
884
+ * function's.
885
+ */
886
+ mergeEvidence(into, evidence) {
887
+ const target = this.store.fact(into);
888
+ if (!target)
889
+ return { ok: false, reason: 'target-missing' };
890
+ if (target.typeName !== '结论')
891
+ return { ok: false, reason: 'target-not-conclusion' };
892
+ if (!isActive(target))
893
+ return { ok: false, reason: 'target-superseded' };
894
+ const held = evidenceOf(target);
895
+ const heldSet = new Set(held);
896
+ const added = this.store.addSlots(into, conclusionSlots(evidence.filter((id) => !heldSet.has(id)), this.entitiesOfFacts(evidence)));
897
+ // Confidence follows evidence count, as the gate does; never lower it.
898
+ const total = new Set([...held, ...evidence]).size;
899
+ const confidence = total >= 5 ? 0.85 : total >= 3 ? 0.7 : target.confidence;
900
+ this.store.setFactConfidence(into, Math.max(target.confidence, confidence));
901
+ return { ok: true, added };
902
+ }
903
+ /** Current conclusions, newest first. Superseded ones are history, not state. */
904
+ conclusions(limit = 20) {
905
+ return this.store
906
+ .facts({ soulId: this.soulId, userId: this.userId, holder: 'self', limit: 200 })
907
+ .filter((fact) => roleOf(fact) === 'judgment' && isActive(fact))
908
+ .slice(0, limit);
909
+ }
910
+ /**
911
+ * The newest facts, newest first — the input batch for consolidation.
912
+ *
913
+ * Observations only: conclusions share the same table, and feeding a conclusion
914
+ * back as evidence would let the model cite its own guess.
915
+ */
916
+ recentFacts(limit = 8) {
917
+ return this.store
918
+ .facts({ soulId: this.soulId, userId: this.userId, limit: limit + 20 })
919
+ .filter((fact) => fact.typeName !== '结论')
920
+ .slice(0, limit);
921
+ }
922
+ /** How many observations exist; consolidation triggers on growth, not time. */
923
+ factTotal() {
924
+ return this.store
925
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 100_000 })
926
+ .filter((fact) => fact.typeName !== '结论').length;
927
+ }
928
+ /**
929
+ * Everything that happened to or around one entity — the "about her" path.
930
+ *
931
+ * `order: 'asc'` gives the earliest fact, which is how "when we first met"
932
+ * is answered without storing a special label: the model reads the timeline.
933
+ */
934
+ factsAbout(entityId, options = {}) {
935
+ return this.store.facts({
936
+ soulId: this.soulId,
937
+ userId: this.userId,
938
+ entityId,
939
+ since: options.since,
940
+ until: options.until,
941
+ order: options.order,
942
+ limit: options.limit,
943
+ });
944
+ }
945
+ /** A fact plus the conversation behind it: hook first, detail on demand. */
946
+ factDetail(factId) {
947
+ const fact = this.store.fact(factId);
948
+ if (!fact)
949
+ return null;
950
+ const turn = fact.sourceTurnId ? this.store.turn(fact.sourceTurnId) : null;
951
+ return {
952
+ fact,
953
+ turn: turn
954
+ ? { id: turn.id, userText: turn.userText, assistantText: turn.assistantText, at: turn.at }
955
+ : undefined,
956
+ };
957
+ }
958
+ /** Open questions, highest tension first. */
959
+ openGaps(limit = 20) {
960
+ return this.store.openGaps(this.soulId, this.userId, limit);
961
+ }
962
+ recordGap(spec) {
963
+ return this.store.putGap({ ...spec, soulId: this.soulId, userId: this.userId });
964
+ }
965
+ closeGap(id, closedByFactId) {
966
+ this.store.closeGap(id, closedByFactId);
967
+ }
968
+ findTurns(text, options = {}) {
969
+ return this.store.findTurns({
970
+ soulId: this.soulId,
971
+ userId: this.userId,
972
+ text,
973
+ sessionId: options.sessionId,
974
+ since: options.since,
975
+ until: options.until,
976
+ limit: options.limit,
977
+ });
978
+ }
979
+ /** Conversations, most recent first, derived from the recorded exchanges. */
980
+ sessions(limit = 5) {
981
+ return this.store.listSessions(this.soulId, this.userId, limit);
982
+ }
983
+ /**
984
+ * What a new session inherits from the previous conversation.
985
+ *
986
+ * Measured: naive first/last picked "ok" and "停" — boundaries are not the
987
+ * same as signals. So the topic is the longest user message of that session
988
+ * (shortest messages carry no topic), and the tail is the last few exchanges
989
+ * whose user message has substance.
990
+ */
991
+ handoff(currentSessionId, tail = 2) {
992
+ const sessions = this.store.listSessions(this.soulId, this.userId, 20);
993
+ const previous = sessions.find((s) => s.sessionId !== currentSessionId);
994
+ if (!previous)
995
+ return null;
996
+ const pairs = this.store.sessionPairs(previous.sessionId, this.soulId, this.userId, 500);
997
+ if (pairs.length === 0)
998
+ return null;
999
+ const SUBSTANTIVE = 4;
1000
+ const substantial = pairs.filter((p) => p.userText.trim().length >= SUBSTANTIVE);
1001
+ const topic = [...substantial].sort((a, b) => b.userText.length - a.userText.length)[0] ?? pairs[0];
1002
+ const last = (substantial.length > 0 ? substantial : pairs).slice(-tail);
1003
+ return { session: previous, first: topic, last };
1004
+ }
1005
+ /**
1006
+ * Verb 3: on demand.
1007
+ *
1008
+ * Reads facts, not the legacy `memories` table: after the migration that table is
1009
+ * inert, and a recall that still read it would stay empty forever.
1010
+ */
1011
+ recall(query, limit = 5) {
1012
+ const facts = this.store
1013
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 500 })
1014
+ .filter((fact) => isActive(fact));
1015
+ const ranked = rank(facts.map(factAsMemory), query, Date.now()).slice(0, limit);
1016
+ return ranked;
1017
+ }
1018
+ /** Pure analysis, no storage. Async because the analyzer may call a model. */
1019
+ async analyze(text) {
1020
+ return this.analyzeTurn(text);
1021
+ }
1022
+ /**
1023
+ * Verb 2: after each turn. Affect and emotion only.
1024
+ *
1025
+ * Memory writing moved to `extract()` + `commitDrafts()` (facts), which is the one
1026
+ * representation the rest of the system reads. This method used to run a second
1027
+ * model pass (`llmExtractTurn`) behind a second gate (`gateDrafts`) into the legacy
1028
+ * `memories` table — two prompts, two gates, two model calls for the same message.
1029
+ */
1030
+ async observe(turn) {
1031
+ const now = turn.at ?? Date.now();
1032
+ const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
223
1033
  if (turn.role === 'user') {
224
1034
  try {
225
1035
  this.store.saveAffect(this.soulId, this.userId, affect, now, this.deviceId);
@@ -231,72 +1041,68 @@ export class Soul {
231
1041
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
232
1042
  this.store.saveEmotion(this.soulId, this.userId, emotion, now);
233
1043
  return {
234
- written,
235
- committed: written > 0,
236
- rejections: gated.rejected,
237
- llmOutcome,
1044
+ written: 0,
1045
+ committed: false,
1046
+ rejections: [],
1047
+ llmOutcome: 'off',
238
1048
  emotion,
239
1049
  affect,
240
- extractedBy: written > 0 ? extractedBy : 'none',
1050
+ extractedBy: 'none',
241
1051
  };
242
1052
  }
243
1053
  /**
244
1054
  * Explicit form of `observe`: the host decided this is worth keeping.
245
- * Same write path, no extraction heuristics.
1055
+ *
1056
+ * Writes a fact, like every other write path. This used to be a row in the legacy
1057
+ * `memories` table, which meant "remember this" produced something the rest of the
1058
+ * system (anchors, recall, conclusions) had to be told about separately.
246
1059
  */
247
1060
  remember(content, options = {}) {
248
- const now = Date.now();
249
- const id = `mem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
250
- this.store.put({
251
- id,
252
- soulId: this.soulId,
253
- userId: this.userId,
254
- type: options.type ?? 'fact',
255
- content,
256
- importance: options.importance ?? 6,
257
- vividness: 0.9,
258
- recallCount: 0,
259
- status: 'active',
260
- visibility: options.visibility ?? 'private',
261
- keywords: tokenize(content),
262
- createdAt: now,
263
- updatedAt: now,
264
- validFrom: now,
1061
+ const fact = this.recordFact({
1062
+ statement: content,
1063
+ typeName: MEMORY_TYPE_TO_FACT[options.type ?? 'fact'],
1064
+ holder: 'self',
1065
+ source: 'told',
1066
+ confidence: Math.min(1, Math.max(0, (options.importance ?? 6) / 10)),
1067
+ // Explicit writes are anchors by definition: the host asked for this to be kept.
1068
+ slots: [{ slot: '锚点', value: { kind: 'text', value: '是' } }],
265
1069
  });
266
- return id;
1070
+ return fact.id;
267
1071
  }
268
1072
  /**
269
- * Explicit write requested by a host or its model (RFC 0005). Unlike
270
- * `remember`, it passes the same gates as `observe` and dedupes by content,
271
- * so a tool cannot bypass shape/semantics or flood duplicates.
1073
+ * Explicit write requested by a host or its model (RFC 0005).
1074
+ *
1075
+ * Writes a fact and keeps the old dedupe contract: exact duplicate returns the
1076
+ * existing id, and a paraphrase inside the dedupe window is treated as the same
1077
+ * statement. Shape checks are the statement checks (non-empty, short enough);
1078
+ * the old `gateDrafts` layer existed only to protect the retired table.
272
1079
  */
273
1080
  rememberExplicit(input) {
274
1081
  const type = input.type ?? 'fact';
275
1082
  const importance = Math.min(10, Math.max(1, input.importance ?? 7));
276
- const { accepted, rejected } = gateDrafts([{ type, content: input.content, importance }]);
277
- if (accepted.length === 0) {
278
- return { ok: false, reason: rejected[0]?.reason ?? 'rejected by write gates' };
279
- }
280
- const content = accepted[0].content;
1083
+ const content = String(input.content ?? '').trim();
1084
+ if (content.length < 2)
1085
+ return { ok: false, reason: `length ${content.length} (2..${MAX_FACT_CHARS})` };
1086
+ if (content.length > MAX_FACT_CHARS)
1087
+ return { ok: false, reason: `length ${content.length} (2..${MAX_FACT_CHARS})` };
1088
+ // Punctuation alone is not a statement; the old shape gate rejected it and that
1089
+ // rule is worth keeping even though its table is gone.
1090
+ if (!/[\p{L}\p{N}]/u.test(content))
1091
+ return { ok: false, reason: 'no letters or digits' };
281
1092
  const now = Date.now();
282
- const candidates = this.store.query({
283
- soulId: this.soulId,
284
- userId: this.userId,
285
- status: ['active'],
286
- includeExpired: true,
287
- limit: 50,
288
- });
1093
+ const candidates = this.store
1094
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 200 })
1095
+ .filter((fact) => isActive(fact));
289
1096
  // Same content anywhere: the exact duplicate.
290
- const same = candidates.find((memory) => memory.content.trim() === content);
1097
+ const same = candidates.find((fact) => fact.statement.trim() === content);
291
1098
  if (same)
292
1099
  return { ok: true, id: same.id, duplicate: true };
293
- // A paraphrase of something just written (usually the automatic observer
294
- // moments earlier) must not become a second row: a shared run of characters
295
- // inside the dedupe window is treated as the same statement.
1100
+ // A paraphrase of something just written must not become a second row: a shared
1101
+ // run of characters inside the dedupe window is treated as the same statement.
296
1102
  const window = this.dedupeWindowMs;
297
1103
  if (window > 0) {
298
- const recent = candidates.filter((memory) => now - memory.createdAt <= window);
299
- const overlapping = recent.find((memory) => sharesRun(memory.content, content, MIN_SHARED_RUN));
1104
+ const recent = candidates.filter((fact) => now - fact.createdAt <= window);
1105
+ const overlapping = recent.find((fact) => sharesRun(fact.statement, content, MIN_SHARED_RUN));
300
1106
  if (overlapping) {
301
1107
  return { ok: true, id: overlapping.id, duplicate: true, reason: 'already recorded this turn' };
302
1108
  }
@@ -304,6 +1110,49 @@ export class Soul {
304
1110
  const id = this.remember(content, { type, importance });
305
1111
  return { ok: true, id };
306
1112
  }
1113
+ /**
1114
+ * One-off: move the legacy `memories` rows into facts (ADR 0032), then retire them.
1115
+ *
1116
+ * The old rows are the only description of the soul's own lessons that exists, so
1117
+ * they are copied — never rewritten — and marked superseded pointing at the new
1118
+ * fact. Re-running is safe: a statement that already has a fact is linked, not
1119
+ * copied again.
1120
+ */
1121
+ migrateLegacyMemories() {
1122
+ const memories = this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 });
1123
+ const existing = new Map(this.store
1124
+ .facts({ soulId: this.soulId, userId: this.userId, limit: 10_000 })
1125
+ .map((fact) => [fact.statement.trim(), fact]));
1126
+ let migrated = 0;
1127
+ let linked = 0;
1128
+ let skipped = 0;
1129
+ for (const memory of memories) {
1130
+ if (memory.status !== 'active') {
1131
+ skipped += 1;
1132
+ continue;
1133
+ }
1134
+ const statement = memory.content.trim();
1135
+ const known = existing.get(statement);
1136
+ const fact = known ??
1137
+ this.recordFact({
1138
+ statement,
1139
+ typeName: MEMORY_TYPE_TO_FACT[memory.type] ?? '事实',
1140
+ holder: 'self',
1141
+ source: 'told',
1142
+ confidence: Math.min(1, Math.max(0, memory.importance / 10)),
1143
+ // Migrated rows were explicit memories; keep them as anchors.
1144
+ slots: [{ slot: '锚点', value: { kind: 'text', value: '是' } }],
1145
+ });
1146
+ if (known)
1147
+ linked += 1;
1148
+ else {
1149
+ existing.set(statement, fact);
1150
+ migrated += 1;
1151
+ }
1152
+ this.store.retireMemory(memory.id, fact.id);
1153
+ }
1154
+ return { migrated, linked, skipped };
1155
+ }
307
1156
  /** Verb 4: session end. */
308
1157
  async reflect(turns, options = {}) {
309
1158
  return reflect(this.store, {
@@ -352,3 +1201,10 @@ function sharesRun(a, b, minRun) {
352
1201
  }
353
1202
  return false;
354
1203
  }
1204
+ /** The fact ids a conclusion cites, in slot order. */
1205
+ function evidenceOf(fact) {
1206
+ return fact.slots
1207
+ .filter((slot) => slot.slot === '证据' && slot.value.kind === 'fact')
1208
+ .map((slot) => (slot.value.kind === 'fact' ? slot.value.factId : ''))
1209
+ .filter(Boolean);
1210
+ }