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,3 +1,10 @@
1
+ /**
2
+ * Reflection: turn a finished session into durable memory + an emotion update.
3
+ *
4
+ * `core` never calls a model itself. If the caller passes an `LlmFn`, richer
5
+ * extraction is possible later; today the rule path is the default, which keeps
6
+ * the whole loop runnable offline.
7
+ */
1
8
  import type { DraftRejection, Emotion, LlmFn, MemoryType, ReflectResult, Turn } from './types.ts';
2
9
  import type { SoulStore } from './store.ts';
3
10
  interface Draft {
@@ -1,12 +1,13 @@
1
- /**
2
- * Reflection: turn a finished session into durable memory + an emotion update.
3
- *
4
- * `core` never calls a model itself. If the caller passes an `LlmFn`, richer
5
- * extraction is possible later; today the rule path is the default, which keeps
6
- * the whole loop runnable offline.
7
- */
8
- import { randomUUID } from 'node:crypto';
9
- import { tokenize } from './scoring.js';
1
+ /** The fact type a legacy MemoryType maps to; one vocabulary at rest (type_name). */
2
+ const MEMORY_TYPE_TO_FACT = {
3
+ semantic: '事实',
4
+ episodic: '事件',
5
+ strategy: '教训',
6
+ cognition: '原则',
7
+ relationship: '关系',
8
+ trait: '性格',
9
+ fact: '事实',
10
+ };
10
11
  /**
11
12
  * A standing instruction needs both halves: when it applies, and what it asks
12
13
  * for. A bare "每次" is usually a complaint ("每次都不能用"), so the trigger
@@ -258,30 +259,27 @@ export async function reflect(store, options) {
258
259
  else {
259
260
  drafts.push(...ruleExtract(options.turns));
260
261
  }
261
- const existing = store.query({ soulId: options.soulId, userId: options.userId });
262
- const seen = new Set(existing.map((m) => m.content));
262
+ // One representation: a reflected draft becomes a fact, not a row in the retired
263
+ // `memories` table. Dedupe is by statement against the facts already held, because
264
+ // the same sentence can arrive through extraction, a tool, or this verb.
265
+ const existing = store.facts({ soulId: options.soulId, userId: options.userId, limit: 1000 });
266
+ const seen = new Set(existing.map((fact) => fact.statement.trim()));
263
267
  const { accepted, rejected } = gateDrafts(drafts);
264
268
  let written = 0;
265
269
  for (const draft of accepted) {
266
- if (seen.has(draft.content))
270
+ const statement = draft.content.trim();
271
+ if (seen.has(statement))
267
272
  continue;
268
- seen.add(draft.content);
269
- const memory = {
270
- id: `mem_${randomUUID().slice(0, 8)}`,
273
+ seen.add(statement);
274
+ store.putFact({
271
275
  soulId: options.soulId,
272
276
  userId: options.userId,
273
- type: draft.type,
274
- content: draft.content,
275
- importance: draft.importance,
276
- vividness: 0.8,
277
- recallCount: 0,
278
- status: 'active',
279
- visibility: draft.type === 'relationship' ? 'private' : 'private',
280
- keywords: tokenize(draft.content),
281
- createdAt: now,
282
- validFrom: now,
283
- };
284
- store.put(memory);
277
+ statement,
278
+ typeName: MEMORY_TYPE_TO_FACT[draft.type] ?? '事实',
279
+ holder: 'self',
280
+ source: 'told',
281
+ confidence: Math.min(1, Math.max(0, draft.importance / 10)),
282
+ });
285
283
  written += 1;
286
284
  }
287
285
  let emotion = store.emotion(options.soulId, options.userId);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Which retrieval primitive a question needs (0020).
3
+ *
4
+ * The mistake this file exists to prevent: treating "上次" as a retrieval method.
5
+ * "上次和 a 吃饭" and "上次和 c 吃饭" are different questions with the same words
6
+ * around them — the subject decides what is fetched, and "上次" only says to order
7
+ * it newest-first. So the router looks for a subject first, and falls back to
8
+ * conversation continuity, then to a time window, then to plain relevance.
9
+ *
10
+ * Rules, not a model: a wrong guess here degrades to relevance search, and never
11
+ * to "answer about somebody else", which is the one failure worse than silence.
12
+ */
13
+ export type RetrievalPrimitive = 'entity' | 'continuity' | 'time' | 'related';
14
+ export interface RetrievalPlan {
15
+ primitive: RetrievalPrimitive;
16
+ /** The entity the question is about, when one is named. */
17
+ subject?: string;
18
+ reason: string;
19
+ }
20
+ /**
21
+ * A named subject the store may not know yet.
22
+ *
23
+ * An unknown name is still a subject: "上次和 c 吃饭" must come back empty, not be
24
+ * treated as "where were we last time" and answered with somebody else's topic.
25
+ * The name stops at the first predicate character, so 小林吃饭 yields 小林.
26
+ *
27
+ * Rejected on purpose: a fragment with no predicate after it ("和数据保持一致" →
28
+ * 数据保持), and any fragment containing a function word ("上次一样", "他一起").
29
+ * Guessing a subject is worse than falling through to relevance search.
30
+ */
31
+ export declare function subjectCandidate(text: string): string | undefined;
32
+ /**
33
+ * Pick a primitive from the question and the entities we know.
34
+ *
35
+ * Longest name first: 小林 must win over 小, and 核桃暗号 over 核桃. A name that is
36
+ * merely a substring of another word is a false positive the caller cannot
37
+ * recover from, so this stays deliberately literal.
38
+ */
39
+ export declare function planRetrieval(text: string, entityNames: string[]): RetrievalPlan;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Which retrieval primitive a question needs (0020).
3
+ *
4
+ * The mistake this file exists to prevent: treating "上次" as a retrieval method.
5
+ * "上次和 a 吃饭" and "上次和 c 吃饭" are different questions with the same words
6
+ * around them — the subject decides what is fetched, and "上次" only says to order
7
+ * it newest-first. So the router looks for a subject first, and falls back to
8
+ * conversation continuity, then to a time window, then to plain relevance.
9
+ *
10
+ * Rules, not a model: a wrong guess here degrades to relevance search, and never
11
+ * to "answer about somebody else", which is the one failure worse than silence.
12
+ */
13
+ const CONTINUITY = /上次|刚才|上回|接着|继续|说到哪|聊到哪/;
14
+ const TIME_CUE = /上周|上星期|昨天|前天|今天|那天|当时|上个月|月初|去年|\d{1,2}月\d{1,2}[日号]/;
15
+ /**
16
+ * Pick a primitive from the question and the entities we know.
17
+ *
18
+ * Longest name first: 小林 must win over 小, and 核桃暗号 over 核桃. A name that is
19
+ * merely a substring of another word is a false positive the caller cannot
20
+ * recover from, so this stays deliberately literal.
21
+ */
22
+ /** "和/跟/与/同 X" — how a companion question names the other person. */
23
+ const WITH_SUBJECT = /(?:和|跟|与|同)\s*([A-Za-z0-9][A-Za-z0-9_]*|[\u4e00-\u9fa5]{1,4})/;
24
+ /** The name ends where the predicate begins. */
25
+ const PREDICATE_START = /[说聊吃见去在是了的吧呢啊吗过还就都也等到]/;
26
+ /** Pronouns are not lookup keys: 我 is the user, 你 is the assistant. */
27
+ const NOT_A_NAME = new Set(['我', '你', '您', '他', '她', '它', '我们', '你们', '他们', '她们']);
28
+ /** Function words inside a candidate: "上次一样" and "他一起" are phrases, not names. */
29
+ const FUNCTION_WORD = /[这那次样起一我你他她它们的]/;
30
+ /** Escape a name before it goes into a RegExp. */
31
+ function escapeRegex(text) {
32
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ }
34
+ /**
35
+ * Does the message actually mention this known entity?
36
+ *
37
+ * A Latin name must stand on its own: the entity "a" is a substring of "package",
38
+ * so `includes` routed ordinary technical messages to `<about name="a">` and
39
+ * suppressed the related-conversation block on most turns. A one-character CJK name
40
+ * is noise (小 is in 小林), but a one-character Latin name is fine when it is a
41
+ * whitespace-delimited token — "上次和 a 吃饭" is a real subject.
42
+ */
43
+ function mentions(question, name) {
44
+ if (/^[A-Za-z0-9_]+$/.test(name)) {
45
+ return new RegExp(`(^|[^A-Za-z0-9_])${escapeRegex(name)}([^A-Za-z0-9_]|$)`, 'i').test(question);
46
+ }
47
+ return [...name].length >= 2 && question.includes(name);
48
+ }
49
+ /**
50
+ * A named subject the store may not know yet.
51
+ *
52
+ * An unknown name is still a subject: "上次和 c 吃饭" must come back empty, not be
53
+ * treated as "where were we last time" and answered with somebody else's topic.
54
+ * The name stops at the first predicate character, so 小林吃饭 yields 小林.
55
+ *
56
+ * Rejected on purpose: a fragment with no predicate after it ("和数据保持一致" →
57
+ * 数据保持), and any fragment containing a function word ("上次一样", "他一起").
58
+ * Guessing a subject is worse than falling through to relevance search.
59
+ */
60
+ export function subjectCandidate(text) {
61
+ const match = WITH_SUBJECT.exec(String(text ?? ''));
62
+ const raw = match?.[1]?.trim();
63
+ if (!raw)
64
+ return undefined;
65
+ // A Latin name is delimited by whitespace ("上次和 c 吃饭"), so it needs no
66
+ // predicate boundary; a CJK run does ("小林吃饭" must stop at 吃).
67
+ let name;
68
+ if (/^[A-Za-z0-9_]+$/.test(raw)) {
69
+ name = raw;
70
+ }
71
+ else {
72
+ const cut = raw.search(PREDICATE_START);
73
+ // No predicate boundary means the conjunction was not naming a person.
74
+ if (cut <= 0)
75
+ return undefined;
76
+ name = raw.slice(0, cut).trim();
77
+ }
78
+ if (!name || NOT_A_NAME.has(name) || FUNCTION_WORD.test(name))
79
+ return undefined;
80
+ return name;
81
+ }
82
+ /**
83
+ * Pick a primitive from the question and the entities we know.
84
+ *
85
+ * Longest name first: 小林 must win over 小, and 核桃暗号 over 核桃. A name that is
86
+ * merely a substring of another word is a false positive the caller cannot
87
+ * recover from, so this stays deliberately literal.
88
+ */
89
+ export function planRetrieval(text, entityNames) {
90
+ const question = String(text ?? '');
91
+ const names = [...new Set(entityNames.filter(Boolean))].sort((a, b) => [...b].length - [...a].length);
92
+ const subject = names.find((name) => mentions(question, name));
93
+ if (subject)
94
+ return { primitive: 'entity', subject, reason: `mentions "${subject}"` };
95
+ // An unknown name is an unknown subject, not an absent one. Looking it up
96
+ // returns nothing, which is the honest answer.
97
+ const unnamed = subjectCandidate(question);
98
+ if (unnamed)
99
+ return { primitive: 'entity', subject: unnamed, reason: `unknown subject "${unnamed}"` };
100
+ if (CONTINUITY.test(question))
101
+ return { primitive: 'continuity', reason: 'continuity cue, no subject' };
102
+ if (TIME_CUE.test(question))
103
+ return { primitive: 'time', reason: 'time cue, no subject' };
104
+ return { primitive: 'related', reason: 'no subject, no cue' };
105
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The two decisions every reader used to make for itself, in one place.
3
+ *
4
+ * During the rebuild the `facts` table changes shape in a way that cannot be flipped at once:
5
+ * - `role` is a new closed field; old rows have none, and their role lives in the Chinese
6
+ * `type_name` label;
7
+ * - `status` moves from none/superseded to raw/candidate/active/retracted, but old rows keep
8
+ * their old values until the migration runs.
9
+ *
10
+ * If each call site guessed, they would guess differently — the exact failure mode that made
11
+ * "规则/画像/待修" both a type and a state. So: one decoder, used everywhere, written to accept
12
+ * both vocabularies until the fallback is deleted in the contract step.
13
+ */
14
+ import type { Fact } from './types.ts';
15
+ import type { FactRole } from './types.ts';
16
+ export declare function roleOf(fact: Pick<Fact, 'role' | 'typeName'>): FactRole;
17
+ export declare function isActive(fact: Pick<Fact, 'status'>): boolean;
18
+ export declare function isRetracted(fact: Pick<Fact, 'status'>): boolean;
@@ -0,0 +1,17 @@
1
+ import { FACT_STATE_MIGRATION, LEGACY_ROLE_BY_TYPE } from './config/domains.js';
2
+ import { FACT_STATUS } from './config/status.js';
3
+ const LEGACY_ROLES = LEGACY_ROLE_BY_TYPE;
4
+ /** Old and new status values that mean "this is in use". */
5
+ const ACTIVE_STATES = new Set(['active', 'none', 'kept', 'open']);
6
+ export function roleOf(fact) {
7
+ if (fact.role)
8
+ return fact.role;
9
+ return LEGACY_ROLES[fact.typeName ?? ''] ?? 'observation';
10
+ }
11
+ export function isActive(fact) {
12
+ return ACTIVE_STATES.has(String(fact.status));
13
+ }
14
+ export function isRetracted(fact) {
15
+ const mapped = FACT_STATE_MIGRATION[String(fact.status)] ?? String(fact.status);
16
+ return mapped === 'retracted' || String(fact.status) === FACT_STATUS.superseded;
17
+ }
@@ -0,0 +1,69 @@
1
+ export interface RuleTrigger {
2
+ /** Substring keywords; any one is enough. */
3
+ any?: string[];
4
+ /** Case-insensitive regex source; any match is enough. */
5
+ regex?: string;
6
+ /** A counter flag maintained by the adapter, e.g. `long-session`. */
7
+ state?: string;
8
+ }
9
+ export interface Rule {
10
+ id: string;
11
+ statement: string;
12
+ trigger: RuleTrigger;
13
+ scope?: string;
14
+ confidence?: number;
15
+ }
16
+ /**
17
+ * Parse the stored trigger. Malformed JSON means "no trigger", not "always":
18
+ * a rule that fires on everything is worse than a rule that never fires.
19
+ */
20
+ export declare function parseTrigger(raw: string): RuleTrigger | null;
21
+ /**
22
+ * Does this rule apply to the current message?
23
+ *
24
+ * With both content and state conditions, both must hold: a rule that mentions
25
+ * both is meant for a narrower situation, and over-firing is the failure that
26
+ * pushes a rule into the wallpaper class.
27
+ */
28
+ export declare function fires(rule: Rule, text: string, state?: Record<string, boolean>): boolean;
29
+ /** The rules to inject: only the ones that fire, strongest first, capped. */
30
+ export declare function selectRules(rules: Rule[], text: string, state?: Record<string, boolean>, limit?: number): Rule[];
31
+ export interface RuleClassification {
32
+ id: string;
33
+ /**
34
+ * `pending` is a third bucket on purpose: a lesson that is neither clearly a
35
+ * triggered rule nor clearly a statement about the person must not be forced into
36
+ * either. As a rule it would become wallpaper; as a profile line it would put an
37
+ * imperative into the description of a person.
38
+ */
39
+ kind: 'rule' | 'profile' | 'pending';
40
+ /** Trigger JSON built by this parser, never authored by the model. */
41
+ trigger?: string;
42
+ /** One sentence of what to do, when this is a rule. */
43
+ action?: string;
44
+ /**
45
+ * Which lens the person statement belongs to, when this is a profile item.
46
+ *
47
+ * Absent when the model did not say or named something that is not a dimension:
48
+ * the merge step refuses to merge unlabelled items rather than guess, so a wrong
49
+ * dimension here would silently file the judgment under the wrong lens.
50
+ */
51
+ dimension?: string;
52
+ /** Why it could not be classified, when pending. */
53
+ reason?: string;
54
+ }
55
+ /**
56
+ * Parse the classifier's answer. Line-table, not JSON — the model fills fields and
57
+ * the code assembles the trigger, because we already know model-authored JSON is a
58
+ * hope (the extractor was moved off it for the same reason).
59
+ *
60
+ * 规则: fc_x | 关键词=json,package.json | 动作=用 edit 工具改 JSON
61
+ * 规则: fc_y | 正则=\.json\b
62
+ * 规则: fc_z | 状态=long-session
63
+ * 画像: fc_p
64
+ * 待修: fc_q | 原因=不知道什么时候该用
65
+ *
66
+ * A 规则 with no usable condition becomes 待修, not 画像: the two failures have
67
+ * different consequences and only one of them pollutes the profile.
68
+ */
69
+ export declare function parseRuleClassifications(raw: string): RuleClassification[];
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Rule matching (ADR 0033).
3
+ *
4
+ * A rule is not a memory: it constrains behaviour, so it should appear when its
5
+ * situation applies and stay out of the way otherwise. Always-on rules become
6
+ * wallpaper, which is how a lesson turns into a paragraph the model skips.
7
+ *
8
+ * The whole thing is a pure function over (rule, user message, session state) so
9
+ * it can be tested without a database or a model call.
10
+ */
11
+ import { PROFILE_DIMENSIONS } from './consolidate.js';
12
+ /**
13
+ * Parse the stored trigger. Malformed JSON means "no trigger", not "always":
14
+ * a rule that fires on everything is worse than a rule that never fires.
15
+ */
16
+ export function parseTrigger(raw) {
17
+ try {
18
+ const parsed = JSON.parse(String(raw ?? ''));
19
+ if (!parsed || typeof parsed !== 'object')
20
+ return null;
21
+ const out = {};
22
+ if (Array.isArray(parsed.any)) {
23
+ const any = parsed.any.map((item) => String(item ?? '').trim()).filter(Boolean);
24
+ if (any.length > 0)
25
+ out.any = any;
26
+ }
27
+ if (typeof parsed.regex === 'string' && parsed.regex.trim())
28
+ out.regex = parsed.regex.trim();
29
+ if (typeof parsed.state === 'string' && parsed.state.trim())
30
+ out.state = parsed.state.trim();
31
+ return out.any || out.regex || out.state ? out : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Content conditions are OR-ed; a state condition must also hold when present. */
38
+ function contentMatches(trigger, text) {
39
+ if (!trigger.any && !trigger.regex)
40
+ return false;
41
+ const haystack = text.toLowerCase();
42
+ if (trigger.any?.some((keyword) => haystack.includes(keyword.toLowerCase())))
43
+ return true;
44
+ if (trigger.regex) {
45
+ try {
46
+ if (new RegExp(trigger.regex, 'i').test(text))
47
+ return true;
48
+ }
49
+ catch {
50
+ // A broken regex must not throw on every turn; it simply never matches.
51
+ return false;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * Does this rule apply to the current message?
58
+ *
59
+ * With both content and state conditions, both must hold: a rule that mentions
60
+ * both is meant for a narrower situation, and over-firing is the failure that
61
+ * pushes a rule into the wallpaper class.
62
+ */
63
+ export function fires(rule, text, state = {}) {
64
+ const trigger = rule.trigger ?? {};
65
+ const hasContent = Boolean(trigger.any || trigger.regex);
66
+ const hasState = Boolean(trigger.state);
67
+ if (!hasContent && !hasState)
68
+ return false; // no trigger = never fires
69
+ if (hasContent && !contentMatches(trigger, String(text ?? '')))
70
+ return false;
71
+ if (hasState && !state[trigger.state])
72
+ return false;
73
+ return true;
74
+ }
75
+ /** The rules to inject: only the ones that fire, strongest first, capped. */
76
+ export function selectRules(rules, text, state = {}, limit = 5) {
77
+ return rules
78
+ .filter((rule) => fires(rule, text, state))
79
+ .sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
80
+ .slice(0, Math.max(0, limit));
81
+ }
82
+ const CLASSIFIED = /^(规则|画像|待修)\s*[::]\s*(.+)$/;
83
+ const KEY_FIELD = /^(关键词|正则|状态|动作|原因|维度)\s*[==]\s*(.*)$/;
84
+ /**
85
+ * Parse the classifier's answer. Line-table, not JSON — the model fills fields and
86
+ * the code assembles the trigger, because we already know model-authored JSON is a
87
+ * hope (the extractor was moved off it for the same reason).
88
+ *
89
+ * 规则: fc_x | 关键词=json,package.json | 动作=用 edit 工具改 JSON
90
+ * 规则: fc_y | 正则=\.json\b
91
+ * 规则: fc_z | 状态=long-session
92
+ * 画像: fc_p
93
+ * 待修: fc_q | 原因=不知道什么时候该用
94
+ *
95
+ * A 规则 with no usable condition becomes 待修, not 画像: the two failures have
96
+ * different consequences and only one of them pollutes the profile.
97
+ */
98
+ export function parseRuleClassifications(raw) {
99
+ const out = [];
100
+ for (const line of String(raw ?? '').split(/\r?\n/)) {
101
+ const match = CLASSIFIED.exec(line.trim());
102
+ if (!match)
103
+ continue;
104
+ const parts = match[2].split('|').map((part) => part.trim());
105
+ const id = parts[0];
106
+ if (!id || !/^fc_/.test(id))
107
+ continue;
108
+ const kindLabel = match[1];
109
+ const fields = new Map();
110
+ for (const part of parts.slice(1)) {
111
+ const field = KEY_FIELD.exec(part);
112
+ if (field)
113
+ fields.set(field[1], field[2].trim());
114
+ }
115
+ if (kindLabel === '画像') {
116
+ const dimension = fields.get('维度');
117
+ // A dimension the merge step does not know is not a dimension; keeping it out
118
+ // leaves the item unlabelled, which is visible, instead of mislabelled.
119
+ const known = dimension && PROFILE_DIMENSIONS.includes(dimension);
120
+ out.push({ id, kind: 'profile', ...(known ? { dimension } : {}) });
121
+ continue;
122
+ }
123
+ if (kindLabel === '待修') {
124
+ out.push({ id, kind: 'pending', reason: fields.get('原因') });
125
+ continue;
126
+ }
127
+ const keywords = (fields.get('关键词') ?? '')
128
+ .split(/[,,、;;\s]+/)
129
+ .map((item) => item.trim())
130
+ .filter(Boolean);
131
+ const regex = fields.get('正则');
132
+ const state = fields.get('状态');
133
+ const trigger = {};
134
+ if (keywords.length > 0)
135
+ trigger.any = keywords;
136
+ if (regex)
137
+ trigger.regex = regex;
138
+ if (state)
139
+ trigger.state = state;
140
+ if (!trigger.any && !trigger.regex && !trigger.state) {
141
+ out.push({ id, kind: 'pending', reason: '规则缺少触发条件' });
142
+ continue;
143
+ }
144
+ out.push({
145
+ id,
146
+ kind: 'rule',
147
+ trigger: JSON.stringify(trigger),
148
+ action: fields.get('动作') || undefined,
149
+ });
150
+ }
151
+ return out;
152
+ }
@@ -1,19 +1,40 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import type { SoulStore } from './store.ts';
3
- export declare const SCHEMA_VERSION = 4;
3
+ export declare const SCHEMA_VERSION = 9;
4
4
  export declare const AFFECT_RETENTION_MS: number;
5
5
  export declare class DbNewerThanBinaryError extends Error {
6
6
  readonly code = "DB_NEWER_THAN_BINARY";
7
7
  readonly dbVersion: number;
8
8
  constructor(dbVersion: number);
9
9
  }
10
+ /**
11
+ * v3/v4 → v5: turns become the unit of memory (briefs/0014). Existing exchanges
12
+ * are copied across as turns with a single text step, so nothing is lost; the
13
+ * old table stays as a read-only legacy copy.
14
+ */
15
+ /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
16
+ export declare function upgradeToV9(db: DatabaseSync): void;
17
+ export declare function upgradeToV8(db: DatabaseSync): void;
18
+ export declare function upgradeToV7(db: DatabaseSync): void;
19
+ export declare function upgradeToV5(db: DatabaseSync): void;
10
20
  /**
11
21
  * Copy the database aside before a schema upgrade.
12
22
  *
13
- * In WAL mode the most recent commits can live in the `-wal` sidecar, so a copy
14
- * of the main file alone can be missing data exactly when it matters. Checkpoint
15
- * first (TRUNCATE moves the WAL back into the main file and empties it), then
16
- * copy. Exported for the upgrade test.
23
+ * The backup is the only way back from a bad migration, so it has to be *valid*,
24
+ * not merely present. Two failures were possible before: a plain file copy after
25
+ * `wal_checkpoint(TRUNCATE)` dropped the newest commits when another connection
26
+ * pinned an old snapshot (the checkpoint returns busy and only partially flushes,
27
+ * and that return value was ignored), and an existing backup file was trusted
28
+ * forever even if it was the broken one from an earlier attempt.
29
+ *
30
+ * `VACUUM INTO` reads through the WAL and writes a consistent snapshot regardless
31
+ * of other readers, so it is the primary path; a checkpoint+copy remains as the
32
+ * fallback, and whatever it produces is opened and integrity-checked. A copy that
33
+ * fails its own check is moved aside (kept for forensics) and the upgrade refuses
34
+ * to run — a corrupt backup is worse than none, because it is the one thing we
35
+ * would trust when everything else failed.
36
+ *
37
+ * Exported for the upgrade test.
17
38
  */
18
39
  export declare function backupBeforeUpgrade(db: DatabaseSync, file: string, from: number): void;
19
40
  export declare function openSqlite(file: string): SoulStore;