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
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
270
|
+
const statement = draft.content.trim();
|
|
271
|
+
if (seen.has(statement))
|
|
267
272
|
continue;
|
|
268
|
-
seen.add(
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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;
|