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
@@ -1,4 +1,7 @@
1
- import { rank } from './scoring.js';
1
+ import { FACT_STATUS } from './config/status.js';
2
+ import { ANCHOR_TYPES } from './config/labels.js';
3
+ import { isActive, roleOf } from './roles.js';
4
+ import { escapeAttr, escapeBlockText } from './inject-text.js';
2
5
  import { selectDirectives } from './affect/directives.js';
3
6
  export const DEFAULT_MAX_CHARS = 1200;
4
7
  /** Characters reserved for the affect section; if it does not fit, it is dropped whole. */
@@ -32,20 +35,25 @@ export function buildContext(store, request, now = Date.now()) {
32
35
  const emotion = store.emotion(request.soulId, request.userId);
33
36
  const maxChars = request.maxChars ?? DEFAULT_MAX_CHARS;
34
37
  const sections = [];
35
- const since = identity ? timeTogether(identity.createdAt, now) : '你刚开始认识这个用户';
36
- sections.push(`<soul name="${name}">\n你是${name},一个持续成长的伙伴。${since}。\n</soul>`);
37
- const styles = styleDirectives(emotion);
38
+ if (request.includeSoul !== false) {
39
+ const since = identity ? timeTogether(identity.createdAt, now) : '你刚开始认识这个用户';
40
+ // The name comes from configuration, but every attribute goes through one rule.
41
+ const safeName = escapeAttr(name);
42
+ sections.push(`<soul name="${safeName}">\n你是${safeName},一个持续成长的伙伴。${since}。\n</soul>`);
43
+ }
44
+ const styles = request.includeStyle === false ? [] : styleDirectives(emotion);
38
45
  if (styles.length > 0) {
39
- sections.push(`<style>\n${styles.map((s) => `- ${s}`).join('\n')}\n</style>`);
46
+ sections.push(`<style>\n${escapeBlockText(styles.map((s) => `- ${s}`).join('\n'))}\n</style>`);
40
47
  }
41
48
  // Affect: newest sample only, and only when it is confident and recent enough.
49
+ // Excluded on request: a stable prompt cannot carry a value that changes every turn.
42
50
  let affectMeta;
43
- const [latest] = store.recentAffect(request.soulId, request.userId, now - AFFECT_WINDOW_MS);
51
+ const [latest] = request.includeAffect === false ? [] : store.recentAffect(request.soulId, request.userId, now - AFFECT_WINDOW_MS);
44
52
  if (latest) {
45
53
  const directives = selectDirectives(latest);
46
54
  if (directives.length > 0) {
47
- const section = `<affect confidence="${latest.confidence.toFixed(2)}" analyzer="${latest.analyzer}">\n` +
48
- `${directives.map((d) => `- ${d.text}`).join('\n')}\n</affect>`;
55
+ const section = `<affect confidence="${latest.confidence.toFixed(2)}" analyzer="${escapeAttr(latest.analyzer)}">\n` +
56
+ `${escapeBlockText(directives.map((d) => `- ${d.text}`).join('\n'))}\n</affect>`;
49
57
  const usedSoFar = sections.join('\n\n').length;
50
58
  if (section.length <= AFFECT_BUDGET && usedSoFar + section.length + 2 <= maxChars) {
51
59
  sections.push(section);
@@ -53,32 +61,55 @@ export function buildContext(store, request, now = Date.now()) {
53
61
  }
54
62
  }
55
63
  }
56
- const candidates = store
57
- .query({ soulId: request.soulId, userId: request.userId })
58
- .filter((memory) => (request.types === undefined || request.types.includes(memory.type)) &&
59
- (request.minImportance === undefined || memory.importance >= request.minImportance));
60
- const ranked = rank(candidates, request.query ?? '', now);
64
+ // Anchors come from two places during the migration: concluded judgments and
65
+ // lessons are facts now (ADR 0032), while the legacy `memories` table is still
66
+ // read until its rows have been migrated and deleted. The union keeps the
67
+ // injection stable across that switch instead of dropping every lesson at once.
68
+ // The anchor vocabulary is declared in config/labels.ts; this file only filters.
69
+ // A fact carrying the 锚点 slot is explicit ("the user asked me to remember this"),
70
+ // so it is injected whatever its type name — otherwise an explicit note typed as
71
+ // 事实 would be stored and never surface.
72
+ const anchorTypes = new Set(ANCHOR_TYPES);
73
+ const explicitAnchor = (fact) => (fact.slots ?? []).some((slot) => slot.slot === '锚点' && slot.value.kind === 'text' && slot.value.value === '是');
74
+ // Roles replace the old type-name check; roleOf() falls back to typeName for rows written
75
+ // before the migration, so anchors stay stable across the switch.
76
+ const ANCHOR_ROLES = new Set(['judgment', 'lesson']);
77
+ const anchors = store
78
+ .facts({ soulId: request.soulId, userId: request.userId, limit: 300 })
79
+ .filter((fact) => isActive(fact) &&
80
+ (ANCHOR_ROLES.has(roleOf(fact)) || anchorTypes.has(fact.typeName ?? '') || explicitAnchor(fact)))
81
+ .map((fact) => ({ id: fact.id, label: fact.typeName ?? '事实', text: fact.statement, score: fact.confidence }));
82
+ for (const memory of store.query({ soulId: request.soulId, userId: request.userId })) {
83
+ if (request.types !== undefined && !request.types.includes(memory.type))
84
+ continue;
85
+ if (request.minImportance !== undefined && memory.importance < request.minImportance)
86
+ continue;
87
+ anchors.push({ id: memory.id, label: memory.type, text: memory.content, score: memory.importance / 10 });
88
+ }
89
+ anchors.sort((a, b) => b.score - a.score);
61
90
  const maxItems = request.maxItems ?? Number.POSITIVE_INFINITY;
62
91
  let chars = sections.join('\n\n').length;
63
92
  let dropped = 0;
64
93
  const lines = [];
65
94
  const used = [];
66
- for (const { memory } of ranked) {
95
+ for (const anchor of anchors) {
67
96
  if (lines.length >= maxItems) {
68
97
  dropped += 1;
69
98
  continue;
70
99
  }
71
- const line = `- [${memory.type}] ${memory.content}`;
100
+ // Escaping can lengthen a line (one `&lt;` is four characters), so cost is charged
101
+ // on the escaped text — the budget has to measure what actually gets injected.
102
+ const line = `- [${escapeBlockText(anchor.label)}] ${escapeBlockText(anchor.text)}`;
72
103
  const cost = line.length + 1 + 10; // line + newline + section overhead share
73
104
  if (chars + cost > maxChars) {
74
105
  dropped += 1;
75
106
  continue;
76
107
  }
77
108
  lines.push(line);
78
- used.push(memory.id);
109
+ used.push(anchor.id);
79
110
  chars += cost;
80
111
  }
81
- if (lines.length > 0)
112
+ if (request.includeAnchors !== false && lines.length > 0)
82
113
  sections.push(`<memories>\n${lines.join('\n')}\n</memories>`);
83
114
  const text = sections.join('\n\n');
84
115
  if (request.track !== false && used.length > 0)
@@ -0,0 +1,6 @@
1
+ import type { ProfileDimension } from './config/dimensions.ts';
2
+ export declare function isProfileDimension(value: unknown): value is ProfileDimension;
3
+ /** The definitions block both prompts embed, generated from the declaration. */
4
+ export declare function renderDimensionDefinitions(): string;
5
+ /** The dimension choice line, generated rather than retyped. */
6
+ export declare function dimensionChoices(): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Decoders for the dimension vocabulary.
3
+ *
4
+ * `config/dimensions.ts` holds the data and nothing else; everything that turns that
5
+ * data into text or validates an answer lives here. The split is what makes the
6
+ * config folder safe to edit: no behaviour hides in a definition file, and the
7
+ * check in health-check enforces it.
8
+ */
9
+ import { PROFILE_DIMENSIONS, PROFILE_DIMENSION_DEFINITIONS } from './config/dimensions.js';
10
+ export function isProfileDimension(value) {
11
+ return typeof value === 'string' && PROFILE_DIMENSIONS.includes(value);
12
+ }
13
+ /** The definitions block both prompts embed, generated from the declaration. */
14
+ export function renderDimensionDefinitions() {
15
+ const lines = PROFILE_DIMENSIONS.map((dimension) => `- ${dimension}:${PROFILE_DIMENSION_DEFINITIONS[dimension]}`);
16
+ return `<dimensions>
17
+ 每个维度是什么,决定什么算一条、什么不算:
18
+ ${lines.join('\n')}
19
+ </dimensions>`;
20
+ }
21
+ /** The dimension choice line, generated rather than retyped. */
22
+ export function dimensionChoices() {
23
+ return `维度=${PROFILE_DIMENSIONS.join('|')}`;
24
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The profile dimension vocabulary, declared once.
3
+ *
4
+ * A dimension is a noun we ship to two model stages and to the merge parser: the
5
+ * classifier decides which lens a lesson belongs to, the merge decides which lens a
6
+ * line belongs to, and the parser rejects anything else. Those three must agree, so
7
+ * the names AND their admission criteria live here and nowhere else — prompts render
8
+ * this, they do not retype it.
9
+ *
10
+ * The definitions are borrowed from measurement traditions on purpose:
11
+ * 价值观 ← Schwartz; 人际关系 ← interpersonal circumplex (warmth × dominance);
12
+ * 生活兴趣 ← RIASEC; 情绪 ← core affect. 边界 has no taxonomy — it quotes the words.
13
+ * These are admission criteria for a sentence, not a personality theory.
14
+ */
15
+ export declare const PROFILE_DIMENSIONS: readonly ['价值观', '工作方式', '沟通偏好', '情绪', '人际关系', '生活兴趣', '边界'];
16
+ export type ProfileDimension = (typeof PROFILE_DIMENSIONS)[number];
17
+ /** What counts as a line in each dimension, and what does not. */
18
+ export declare const PROFILE_DIMENSION_DEFINITIONS: Record<ProfileDimension, string>;
19
+ export declare function isProfileDimension(value: unknown): value is ProfileDimension;
20
+ /**
21
+ * Render the definitions block both prompts embed.
22
+ *
23
+ * Generated from the declaration above so a new dimension cannot be added to the
24
+ * vocabulary without appearing in the prompt (and vice versa).
25
+ */
26
+ export declare function renderDimensionDefinitions(): string;
27
+ /** The "维度=价值观|工作方式|…" line, generated rather than retyped. */
28
+ export declare function dimensionChoices(): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The profile dimension vocabulary, declared once.
3
+ *
4
+ * A dimension is a noun we ship to two model stages and to the merge parser: the
5
+ * classifier decides which lens a lesson belongs to, the merge decides which lens a
6
+ * line belongs to, and the parser rejects anything else. Those three must agree, so
7
+ * the names AND their admission criteria live here and nowhere else — prompts render
8
+ * this, they do not retype it.
9
+ *
10
+ * The definitions are borrowed from measurement traditions on purpose:
11
+ * 价值观 ← Schwartz; 人际关系 ← interpersonal circumplex (warmth × dominance);
12
+ * 生活兴趣 ← RIASEC; 情绪 ← core affect. 边界 has no taxonomy — it quotes the words.
13
+ * These are admission criteria for a sentence, not a personality theory.
14
+ */
15
+ export const PROFILE_DIMENSIONS = [
16
+ '价值观',
17
+ '工作方式',
18
+ '沟通偏好',
19
+ '情绪',
20
+ '人际关系',
21
+ '生活兴趣',
22
+ '边界',
23
+ ];
24
+ /** What counts as a line in each dimension, and what does not. */
25
+ export const PROFILE_DIMENSION_DEFINITIONS = {
26
+ 价值观: '超越具体情境、值得追求的目标(Schwartz)。"想把事做对"算;"这个接口用 POST"不算。',
27
+ 工作方式: '面对任务时的稳定做法。"先验证再动手"算;"这次改了 A 文件"不算。',
28
+ 沟通偏好: '希望信息以什么形式、什么节奏出现。"整轮对话当整体"算。',
29
+ 情绪: '对具体事件的感受与强度。"被反复确认会烦"算;"今天天气"不算。',
30
+ 人际关系: '与特定人的位置和距离(温暖 × 支配)。"她最近压力大"算。',
31
+ 生活兴趣: '自愿投入时间的事。"喜欢爬山"算。',
32
+ 边界: '明确说过的"不要"。没有公认分类学,必须贴着原话,不许概括。',
33
+ };
34
+ export function isProfileDimension(value) {
35
+ return typeof value === 'string' && PROFILE_DIMENSIONS.includes(value);
36
+ }
37
+ /**
38
+ * Render the definitions block both prompts embed.
39
+ *
40
+ * Generated from the declaration above so a new dimension cannot be added to the
41
+ * vocabulary without appearing in the prompt (and vice versa).
42
+ */
43
+ export function renderDimensionDefinitions() {
44
+ const lines = PROFILE_DIMENSIONS.map((dimension) => `- ${dimension}:${PROFILE_DIMENSION_DEFINITIONS[dimension]}`);
45
+ return `<dimensions>
46
+ 每个维度是什么,决定什么算一条、什么不算:
47
+ ${lines.join('\n')}
48
+ </dimensions>`;
49
+ }
50
+ /** The "维度=价值观|工作方式|…" line, generated rather than retyped. */
51
+ export function dimensionChoices() {
52
+ return `维度=${PROFILE_DIMENSIONS.join('|')}`;
53
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Extraction (spec/model.v0.md): turn text → drafts of entities, facts, gaps.
3
+ *
4
+ * Three rules that shape everything here:
5
+ *
6
+ * 1. Drafts, not writes. Extraction produces candidates; committing them is a
7
+ * separate step with its own gate (entity resolution, dedupe). Drafts are
8
+ * easy to test and bad drafts never reach the database.
9
+ * 2. Rules first. Most sentences in a conversation are templated ("我同事小林
10
+ * 上周被裁员了"), and a rule is fast, explainable and testable. The model is
11
+ * only asked when the rules miss — synthesis is the model's job, parsing is
12
+ * the code's (ADR 0027).
13
+ * 3. Nothing is invented. A rule that cannot decide emits less, not a guess:
14
+ * an unknown time stays `atText`, an unresolved "她" stays unresolved.
15
+ *
16
+ * Pure functions, no I/O, no dependencies.
17
+ */
18
+ import type { ThreadAttribution, ExtractDraft } from './types.ts';
19
+ /**
20
+ * What a fact's confidence is when the model did not give one.
21
+ *
22
+ * The midpoint is the only honest value: 0 says "probably false" and 1 says
23
+ * "certain", and neither is what "no answer" means. Used by both the text and the
24
+ * JSON paths so the two shapes cannot disagree.
25
+ */
26
+ export declare const UNKNOWN_CONFIDENCE = 0.6;
27
+ /**
28
+ * A calendar date, or nothing.
29
+ *
30
+ * `Date.parse('2026-02-31T00:00:00Z')` does not fail: it rolls to 2026-03-03. A rolled
31
+ * date is worse than a missing one — it is confidently wrong and joins time retrieval
32
+ * as if it were exact. Round-tripping through Date is the only reliable check.
33
+ */
34
+ export declare function calendarDate(year: number, month: number, day: number): number | undefined;
35
+ /** Parse `YYYY-MM-DD`; anything that is not a real day on the calendar returns undefined. */
36
+ export declare function parseIsoDate(text: string): number | undefined;
37
+ /**
38
+ * Rule extraction. Returns drafts for one turn's user text; may return an empty
39
+ * list, which is a valid answer (small talk is not a fact).
40
+ *
41
+ * Pronouns are resolved only inside the same turn, and only to the most recent
42
+ * explicit person: "我同事小林被裁了。不知道她后来怎么样" can bind 她→小林 because
43
+ * the turn itself said who she is. Across turns we do not guess — an unresolved
44
+ * "她" stays unresolved instead of being attached to the wrong person.
45
+ */
46
+ export declare function extractByRules(text: string, at?: number): ExtractDraft;
47
+ interface ModelParseResult {
48
+ draft: ExtractDraft | null;
49
+ error?: string;
50
+ }
51
+ /** What the model is allowed to know about the store before extracting. */
52
+ export interface ExtractionContext {
53
+ entities: Array<{
54
+ id: string;
55
+ name: string;
56
+ aliases?: string[];
57
+ relation?: string;
58
+ }>;
59
+ facts: Array<{
60
+ id: string;
61
+ statement: string;
62
+ atText?: string;
63
+ }>;
64
+ previousUserText?: string;
65
+ /** Open threads, for attribution (labels plus their last lines, ADR 0034). */
66
+ threads?: Array<{
67
+ id: string;
68
+ topic: string;
69
+ ago?: number;
70
+ recent?: Array<{
71
+ role: 'user' | 'assistant';
72
+ text: string;
73
+ }>;
74
+ }>;
75
+ previousThreadId?: string;
76
+ }
77
+ /**
78
+ * Model extraction: one call, strict JSON, validated afterwards.
79
+ *
80
+ * The model sees the entities and facts already stored, so it can reuse a name
81
+ * instead of inventing a duplicate and can supersede a fact that the new
82
+ * sentence contradicts. That merge decision is the difference between "a log of
83
+ * sentences" and memory (Mem0's ADD/UPDATE/DELETE, Graphiti's invalidation).
84
+ *
85
+ * The prompt itself is rendered from `prompts.ts`, not written here.
86
+ */
87
+ export declare function extractWithModel(text: string, llm: (prompt: string) => Promise<string> | string, at?: number, context?: ExtractionContext, assistantText?: string): Promise<ModelParseResult>;
88
+ /**
89
+ * Parse the attribution line.
90
+ *
91
+ * 线索: 继续 th_ab12
92
+ * 线索: 新 | 在聊 cookie 与认证
93
+ */
94
+ export declare function parseThreadLine(raw: string): ThreadAttribution | null;
95
+ /**
96
+ * What the model's thread line said, including whether it said anything.
97
+ *
98
+ * `parseThreadLine` collapses "no line" and "a line I cannot read" into null, which made
99
+ * the fallback rate unmeasurable — 75% of attributions arrived as `fallback-previous`
100
+ * with no way to tell whether the model was silent or unparseable. The kinds are kept
101
+ * apart here; only `continue` and `new` are usable attributions.
102
+ */
103
+ export declare function readThreadLine(raw: string): {
104
+ kind: 'continue';
105
+ id: string;
106
+ topic?: string;
107
+ } | {
108
+ kind: 'new';
109
+ topic?: string;
110
+ } | {
111
+ kind: 'absent';
112
+ } | {
113
+ kind: 'invalid';
114
+ line: string;
115
+ };
116
+ /**
117
+ * Validate and normalise a model answer. Anything malformed is dropped rather
118
+ * than repaired: a missing statement cannot be invented, and a bad entity type
119
+ * would poison retrieval later.
120
+ *
121
+ * `supersede` / `update` must point at a fact the model was actually shown —
122
+ * otherwise it could retire a memory it never read.
123
+ */
124
+ export declare function validateDraft(json: unknown, at?: number, context?: ExtractionContext): ModelParseResult;
125
+ /**
126
+ * Parse the line template the model is asked to fill in.
127
+ *
128
+ * Requiring valid JSON makes the whole extraction hinge on one character:
129
+ * a missing brace turns a correct answer into "model-failed". A line template
130
+ * fails per line instead, so one malformed record costs one record. The shape it
131
+ * builds is the same object `validateDraft` already checks, so there is one gate,
132
+ * not two.
133
+ *
134
+ * 实体: 小林 | person | 同事
135
+ * 事实: 陈述 | 类型名 | 2026-10-24 | 上周 | told | 0.8 | 被裁员者=小林
136
+ * 缺口: 怎么了 | 0.6 | 小林
137
+ * 无
138
+ */
139
+ export declare function parseLineDraft(raw: string): Record<string, unknown> | null;
140
+ export {};