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.
- package/LICENSE +202 -202
- package/dist/packages/cli/src/cli.js +0 -0
- package/dist/packages/core/src/config/dimensions.d.ts +18 -0
- package/dist/packages/core/src/config/dimensions.js +33 -0
- package/dist/packages/core/src/config/domains.d.ts +70 -0
- package/dist/packages/core/src/config/domains.js +70 -0
- package/dist/packages/core/src/config/labels.d.ts +22 -0
- package/dist/packages/core/src/config/labels.js +21 -0
- package/dist/packages/core/src/config/status.d.ts +51 -0
- package/dist/packages/core/src/config/status.js +47 -0
- package/dist/packages/core/src/consolidate.d.ts +170 -0
- package/dist/packages/core/src/consolidate.js +311 -0
- package/dist/packages/core/src/context.d.ts +2 -0
- package/dist/packages/core/src/context.js +48 -17
- package/dist/packages/core/src/dimension-text.d.ts +6 -0
- package/dist/packages/core/src/dimension-text.js +24 -0
- package/dist/packages/core/src/dimensions.d.ts +28 -0
- package/dist/packages/core/src/dimensions.js +53 -0
- package/dist/packages/core/src/extract.d.ts +140 -0
- package/dist/packages/core/src/extract.js +611 -0
- package/dist/packages/core/src/index.d.ts +362 -10
- package/dist/packages/core/src/index.js +983 -127
- package/dist/packages/core/src/inject-text.d.ts +16 -0
- package/dist/packages/core/src/inject-text.js +24 -0
- package/dist/packages/core/src/profile-prompt.d.ts +18 -0
- package/dist/packages/core/src/profile-prompt.js +103 -0
- package/dist/packages/core/src/prompts.d.ts +122 -0
- package/dist/packages/core/src/prompts.generated.d.ts +9 -0
- package/dist/packages/core/src/prompts.generated.js +9 -0
- package/dist/packages/core/src/prompts.js +293 -0
- package/dist/packages/core/src/reflect.d.ts +7 -0
- package/dist/packages/core/src/reflect.js +25 -27
- package/dist/packages/core/src/retrieval.d.ts +39 -0
- package/dist/packages/core/src/retrieval.js +105 -0
- package/dist/packages/core/src/roles.d.ts +18 -0
- package/dist/packages/core/src/roles.js +17 -0
- package/dist/packages/core/src/rules.d.ts +69 -0
- package/dist/packages/core/src/rules.js +152 -0
- package/dist/packages/core/src/sqlite.d.ts +26 -5
- package/dist/packages/core/src/sqlite.js +944 -33
- package/dist/packages/core/src/store.d.ts +92 -3
- package/dist/packages/core/src/types.d.ts +442 -1
- package/dist/packages/core/src/types.js +1 -0
- 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 {
|
|
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
|
|
73
|
-
soulId: this.soulId,
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
*
|
|
318
|
+
* Put a turn into a thread.
|
|
155
319
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
236
|
-
rejections:
|
|
237
|
-
llmOutcome,
|
|
1044
|
+
written: 0,
|
|
1045
|
+
committed: false,
|
|
1046
|
+
rejections: [],
|
|
1047
|
+
llmOutcome: 'off',
|
|
238
1048
|
emotion,
|
|
239
1049
|
affect,
|
|
240
|
-
extractedBy:
|
|
1050
|
+
extractedBy: 'none',
|
|
241
1051
|
};
|
|
242
1052
|
}
|
|
243
1053
|
/**
|
|
244
1054
|
* Explicit form of `observe`: the host decided this is worth keeping.
|
|
245
|
-
*
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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).
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
|
277
|
-
if (
|
|
278
|
-
return { ok: false, reason:
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
283
|
-
soulId: this.soulId,
|
|
284
|
-
|
|
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((
|
|
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
|
|
294
|
-
//
|
|
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((
|
|
299
|
-
const overlapping = recent.find((
|
|
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
|
+
}
|