soulbridge 0.0.102 → 0.0.103

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.
@@ -245,8 +245,10 @@ export function parseProfileMerge(raw, allowed, expected) {
245
245
  // model mixed two lenses back together, which is the welding the split prevents.
246
246
  if (expected && dimension !== expected)
247
247
  continue;
248
- const sourceField = fields.find((field) => field.startsWith('来源'));
249
- const sources = (sourceField?.replace(/^来源\s*[==]\s*/, '') ?? '')
248
+ // One name for the field now; 来源 is accepted only so an older prompt in flight
249
+ // still parses.
250
+ const sourceField = fields.find((field) => /^(证据|来源)\s*[==]/.test(field));
251
+ const sources = (sourceField?.replace(/^(证据|来源)\s*[==]\s*/, '') ?? '')
250
252
  .split(/[,,、;;\s]+/)
251
253
  .map((id) => id.trim())
252
254
  .filter((id) => allowed.has(id));
@@ -277,7 +279,9 @@ export function parseProfileMerge(raw, allowed, expected) {
277
279
  * so both are read — a line that names its sources can never be merge input.
278
280
  */
279
281
  export function isDerivedProfileLine(fact) {
280
- return (fact.slots ?? []).some((slot) => slot.slot === '来源' || slot.slot === '合并');
282
+ // 证据 is the one name for "this line cites its sources"; 来源 was the old spelling and 合并
283
+ // marks a merged line that has no sources of its own.
284
+ return (fact.slots ?? []).some((slot) => slot.slot === '证据' || slot.slot === '合并');
281
285
  }
282
286
  /**
283
287
  * Cut the merge input along dimensions before any model sees it.
@@ -181,7 +181,9 @@ export class Soul {
181
181
  * code would be the guess ADR 0027 forbids.
182
182
  */
183
183
  promotionCandidates(turnId) {
184
- return this.store.factsByTurns([turnId]).filter((fact) => fact.slots.some((slot) => slot.slot === '原则候选' && slot.value.kind === 'text' && slot.value.value === '是'));
184
+ return this.store
185
+ .factsByTurns([turnId], { includeCandidates: true })
186
+ .filter((fact) => fact.role === 'lesson' && fact.status === 'candidate');
185
187
  }
186
188
  /**
187
189
  * Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
@@ -494,11 +496,11 @@ export class Soul {
494
496
  skipped.push({ what: statement, reason: 'duplicate' });
495
497
  return null;
496
498
  }
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: '是' } });
499
+ // An unclassified lesson is a candidate: a lesson the model flagged but did not
500
+ // place in a role. It is held and shown to the classifier, injected nowhere.
501
+ // This replaced the old `原则候选` slot, which was the same decision stored as text.
502
+ const role = factDraft.principle && !factDraft.role ? 'lesson' : factDraft.role;
503
+ const status = factDraft.principle && !factDraft.role ? 'candidate' : undefined;
502
504
  // ADR 0035: a fact from my side of the exchange is `inferred`, and inferred
503
505
  // claims must carry their evidence. The turn id is the evidence available here —
504
506
  // claims that need a tool or test result are kept out by the prompt instead.
@@ -510,7 +512,8 @@ export class Soul {
510
512
  userId: this.userId,
511
513
  statement,
512
514
  holder,
513
- role: factDraft.role,
515
+ role,
516
+ status,
514
517
  typeName: factDraft.typeName,
515
518
  at: factDraft.at,
516
519
  atText: factDraft.atText,
@@ -747,7 +750,7 @@ export class Soul {
747
750
  // More sources means more observations behind the line, so it ranks first.
748
751
  confidence: Math.min(0.95, 0.6 + 0.05 * line.sources.length),
749
752
  slots: [
750
- ...line.sources.map((id) => ({ slot: '来源', value: { kind: 'fact', factId: id } })),
753
+ ...line.sources.map((id) => ({ slot: '证据', value: { kind: 'fact', factId: id } })),
751
754
  // A merged line is not an original: re-merging merged lines loses detail.
752
755
  { slot: '合并', value: { kind: 'text', value: '1' } },
753
756
  { slot: '维度', value: { kind: 'text', value: line.dimension } },
@@ -41,7 +41,7 @@ const PROFILE_RULES = `<rules>
41
41
  - 不许出现"一切/所有/总是/从不",除非来源原文里就有。
42
42
  - 每行给一个维度:${dimensionChoices()}
43
43
  - 一行只讲一个维度。不同维度必须分行——跨维度拼成一句就是没拆。
44
- - 每条必须给来源:来源=fc_xxx,fc_yyy,只能引用 <input> 里出现过的 id;没有来源的整行不要。
44
+ - 每条必须给证据:证据=fc_xxx,fc_yyy,只能引用 <input> 里出现过的 id;没有证据的整行不要。
45
45
  - 合并后 5–8 条;宁少不凑。
46
46
  - 输入里没有的新判断,不许出现。
47
47
  </rules>`;
@@ -68,8 +68,8 @@ const PROFILE_EXAMPLE = `<example>
68
68
  - fc_c | 用户很讨厌一件事反复讲,换工具又要重新交代
69
69
  </input>
70
70
  <output>
71
- 画像: 要机制,不要靠 prompt 的自觉 | 维度=${PROFILE_DIMENSIONS[1]} | 来源=fc_a,fc_b
72
- 画像: 讨厌重复交代,工具换了也不该重讲 | 维度=${PROFILE_DIMENSIONS[2]} | 来源=fc_c
71
+ 画像: 要机制,不要靠 prompt 的自觉 | 维度=${PROFILE_DIMENSIONS[1]} | 证据=fc_a,fc_b
72
+ 画像: 讨厌重复交代,工具换了也不该重讲 | 维度=${PROFILE_DIMENSIONS[2]} | 证据=fc_c
73
73
  </output>
74
74
  </example>`;
75
75
  /**
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import type { SoulStore } from './store.ts';
3
- export declare const SCHEMA_VERSION = 10;
3
+ export declare const SCHEMA_VERSION = 11;
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";
@@ -13,6 +13,7 @@ export declare class DbNewerThanBinaryError extends Error {
13
13
  * old table stays as a read-only legacy copy.
14
14
  */
15
15
  /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
16
+ export declare function upgradeToV11(db: DatabaseSync): void;
16
17
  export declare function upgradeToV10(db: DatabaseSync): void;
17
18
  export declare function upgradeToV9(db: DatabaseSync): void;
18
19
  export declare function upgradeToV8(db: DatabaseSync): void;
@@ -12,7 +12,7 @@ import { DEFAULT_EMOTION } from './types.js';
12
12
  import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
13
13
  import { FACT_ROLES } from './config/domains.js';
14
14
  import { bm25, buildCorpusStats, tokenize } from './scoring.js';
15
- export const SCHEMA_VERSION = 10;
15
+ export const SCHEMA_VERSION = 11;
16
16
  /** How many recent pairs are considered before scoring. Bounded so recall stays O(candidates). */
17
17
  const RECALL_CANDIDATES = 500;
18
18
  export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
@@ -299,6 +299,21 @@ function upgradeToV3(db) {
299
299
  * old table stays as a read-only legacy copy.
300
300
  */
301
301
  /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
302
+ export function upgradeToV11(db) {
303
+ // 原则候选 stored a decision as a slot; it is role+status now. The row becomes a lesson
304
+ // candidate — held and shown to the classifier, injected nowhere until it is placed.
305
+ db.exec(`UPDATE facts SET role = 'lesson', status = 'candidate'
306
+ WHERE id IN (SELECT fact_id FROM fact_slots WHERE slot = '原则候选' AND value_text = '是')`);
307
+ db.exec("DELETE FROM fact_slots WHERE slot = '原则候选'");
308
+ // One name for "this cites its sources": 来源 → 证据. Skip where the fact already cites
309
+ // the same row under the new name, then drop what is left of the old spelling.
310
+ db.exec(`UPDATE fact_slots SET slot = '证据'
311
+ WHERE slot = '来源'
312
+ AND NOT EXISTS (SELECT 1 FROM fact_slots x
313
+ WHERE x.fact_id = fact_slots.fact_id AND x.slot = '证据'
314
+ AND x.ref_fact_id = fact_slots.ref_fact_id)`);
315
+ db.exec("DELETE FROM fact_slots WHERE slot = '来源'");
316
+ }
302
317
  export function upgradeToV10(db) {
303
318
  // The four-state vocabulary (config/status.ts). Everything written under the old names
304
319
  // becomes one of the two states the code now reads; after this the fallbacks are gone.
@@ -448,8 +463,8 @@ export function openSqlite(file) {
448
463
  upgradeToV7(db);
449
464
  if (version < 8)
450
465
  upgradeToV8(db);
451
- if (version < 10)
452
- upgradeToV10(db);
466
+ if (version < 11)
467
+ upgradeToV11(db);
453
468
  // v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
454
469
  setVersion(db, SCHEMA_VERSION);
455
470
  db.exec('COMMIT');
@@ -1006,12 +1021,19 @@ export function openSqlite(file) {
1006
1021
  const text = row ? String(row.user_text ?? '').trim() : '';
1007
1022
  return text === '' ? null : text;
1008
1023
  },
1009
- factsByTurns(turnIds) {
1024
+ factsByTurns(turnIds, options = {}) {
1010
1025
  if (turnIds.length === 0)
1011
1026
  return [];
1012
1027
  const placeholders = turnIds.map(() => '?').join(',');
1028
+ // Only live facts. A retracted statement must not become evidence for the next
1029
+ // consolidation; a candidate is held but unconfirmed, so it is evidence for nothing
1030
+ // except the promotion step that decides its role.
1031
+ const live = options.includeCandidates
1032
+ ? `f.status IN ('${FACT_STATUS.active}','${FACT_STATUS.candidate}')`
1033
+ : `f.status = '${FACT_STATUS.active}'`;
1013
1034
  const rows = db
1014
- .prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders}) ORDER BY f.created_at ASC`)
1035
+ .prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders})
1036
+ AND ${live} ORDER BY f.created_at ASC`)
1015
1037
  .all(...turnIds);
1016
1038
  const slots = slotsOfRows(db, rows.map((row) => String(row.id)));
1017
1039
  return rows.map((row) => rowToFact(row, slots));
@@ -88,8 +88,13 @@ export interface SoulStore {
88
88
  lastClosedThread(soulId: string, userId: string): TurnThread | null;
89
89
  /** Every turn of a thread, in order — the chapter, not one page. */
90
90
  threadTurns(threadId: string): TurnPair[];
91
- /** Facts whose source turn belongs to the given turns. */
92
- factsByTurns(turnIds: string[]): Fact[];
91
+ /**
92
+ * Facts whose source turn belongs to the given turns. Live facts only; candidates are
93
+ * included when asked, because promotion needs to see lessons not yet classified.
94
+ */
95
+ factsByTurns(turnIds: string[], options?: {
96
+ includeCandidates?: boolean;
97
+ }): Fact[];
93
98
  /** One turn by id, for expanding a fact back into its conversation. */
94
99
  turn(id: string): TurnPair | null;
95
100
  /** Retire a fact: the new one replaces it, the old row stays for the record. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.102",
3
+ "version": "0.0.103",
4
4
  "description": "A personality layer for any agent: continuity, forgetting, and growth across tools.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",