soulbridge 0.0.102 → 0.0.104

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.
@@ -14,7 +14,7 @@
14
14
  * soulbridge demo
15
15
  */
16
16
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
- import { dirname, resolve } from 'node:path';
17
+ import { dirname, join, resolve } from 'node:path';
18
18
  // node:sqlite prints an ExperimentalWarning the moment it loads. Replace the default
19
19
  // printer with a filter: the CLI's own output stays clean, other warnings still show.
20
20
  // Must run before the dynamic import below, which is why that import is dynamic.
@@ -221,9 +221,27 @@ async function main() {
221
221
  return;
222
222
  }
223
223
  case 'export': {
224
+ // Two shapes: a legacy JSONL of memories (--out), and the portable bundle (--dir)
225
+ // that carries the whole record, a readable summary and the current injection.
226
+ if (flags.dir) {
227
+ const { soul } = open(flags);
228
+ const dir = resolve(String(flags.dir));
229
+ mkdirSync(dir, { recursive: true });
230
+ const bundle = soul.portableBundle();
231
+ writeFileSync(join(dir, 'archive.jsonl'), `${bundle.archive.join('\n')}\n`, 'utf8');
232
+ writeFileSync(join(dir, 'summary.md'), bundle.summary, 'utf8');
233
+ writeFileSync(join(dir, 'injection.txt'), bundle.injection, 'utf8');
234
+ out({
235
+ ok: true,
236
+ dir,
237
+ records: bundle.archive.length,
238
+ files: ['archive.jsonl', 'summary.md', 'injection.txt'],
239
+ });
240
+ return;
241
+ }
224
242
  const file = flags.out;
225
243
  if (!file)
226
- throw new Error('usage: soulbridge export --out soulbridge.jsonl');
244
+ throw new Error('usage: soulbridge export --out <file> | --dir <directory>');
227
245
  const { soul, deviceId } = open(flags);
228
246
  const body = exportJsonl(soul, deviceId);
229
247
  const target = resolve(file);
@@ -425,6 +443,7 @@ async function main() {
425
443
  ' recall <q> [--limit N] 召回',
426
444
  ' reflect --text|--file 会话反思',
427
445
  ' export --out <file> 导出 JSONL',
446
+ ' export --dir <directory> 导出可携带档案(archive/summary/injection)',
428
447
  ' import --in <file> 导入 JSONL',
429
448
  ' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
430
449
  ' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
@@ -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.
@@ -30,7 +30,7 @@ export * from './storage/config.ts';
30
30
  export * from './storage/merge.ts';
31
31
  export * from './storage/steering.ts';
32
32
  export * from './storage/transcript.ts';
33
- import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, CommitResult, Entity, ExtractDraft, ConclusionDraft, Fact, ThreadAttribution, TurnThread, FactDetail, FactSpec, Gap, GapSpec, SessionHandoff, TurnFindResult, SessionSummary, TurnPair, TurnStep, Visibility } from './types.ts';
33
+ import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, CommitResult, Entity, ExtractDraft, ConclusionDraft, Fact, ThreadAttribution, TurnThread, FactDetail, FactSpec, Gap, GapSpec, SessionHandoff, TurnFindResult, SessionSummary, TurnPair, TurnStep, PortableBundle, Visibility } from './types.ts';
34
34
  import type { SoulStore } from './store.ts';
35
35
  import type { AffectAnalyzer } from './affect/port.ts';
36
36
  import type { Rule } from './rules.ts';
@@ -80,6 +80,11 @@ export declare class Soul {
80
80
  readonly dedupeWindowMs: number;
81
81
  readonly onLlmRaw?: (raw: string) => void;
82
82
  constructor(options: SoulOptions);
83
+ /**
84
+ * Everything a host needs to carry this soul to another machine or tool:
85
+ * the full archive, a readable summary, and the injection the model would see.
86
+ */
87
+ portableBundle(now?: number): PortableBundle;
83
88
  /** Verb 1: session start. */
84
89
  context(request?: Partial<ContextRequest>): ContextResult;
85
90
  /**
@@ -21,6 +21,7 @@ export * from './storage/config.js';
21
21
  export * from './storage/merge.js';
22
22
  export * from './storage/steering.js';
23
23
  export * from './storage/transcript.js';
24
+ import { buildPortableBundle } from './portable.js';
24
25
  import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
25
26
  import { HOLDER_WORDS } from './config/domains.js';
26
27
  import { isActive, isRetracted, roleOf } from './roles.js';
@@ -100,6 +101,15 @@ export class Soul {
100
101
  });
101
102
  }
102
103
  }
104
+ /**
105
+ * Everything a host needs to carry this soul to another machine or tool:
106
+ * the full archive, a readable summary, and the injection the model would see.
107
+ */
108
+ portableBundle(now = Date.now()) {
109
+ const dump = this.store.dump(this.soulId, this.userId);
110
+ const injection = this.context({}).text;
111
+ return buildPortableBundle(dump, this.soulId, this.userId, injection, now);
112
+ }
103
113
  /** Verb 1: session start. */
104
114
  context(request = {}) {
105
115
  // Spread, not a field list: an explicit list silently dropped every option
@@ -181,7 +191,9 @@ export class Soul {
181
191
  * code would be the guess ADR 0027 forbids.
182
192
  */
183
193
  promotionCandidates(turnId) {
184
- return this.store.factsByTurns([turnId]).filter((fact) => fact.slots.some((slot) => slot.slot === '原则候选' && slot.value.kind === 'text' && slot.value.value === '是'));
194
+ return this.store
195
+ .factsByTurns([turnId], { includeCandidates: true })
196
+ .filter((fact) => fact.role === 'lesson' && fact.status === 'candidate');
185
197
  }
186
198
  /**
187
199
  * Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
@@ -494,11 +506,11 @@ export class Soul {
494
506
  skipped.push({ what: statement, reason: 'duplicate' });
495
507
  return null;
496
508
  }
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: '是' } });
509
+ // An unclassified lesson is a candidate: a lesson the model flagged but did not
510
+ // place in a role. It is held and shown to the classifier, injected nowhere.
511
+ // This replaced the old `原则候选` slot, which was the same decision stored as text.
512
+ const role = factDraft.principle && !factDraft.role ? 'lesson' : factDraft.role;
513
+ const status = factDraft.principle && !factDraft.role ? 'candidate' : undefined;
502
514
  // ADR 0035: a fact from my side of the exchange is `inferred`, and inferred
503
515
  // claims must carry their evidence. The turn id is the evidence available here —
504
516
  // claims that need a tool or test result are kept out by the prompt instead.
@@ -510,7 +522,8 @@ export class Soul {
510
522
  userId: this.userId,
511
523
  statement,
512
524
  holder,
513
- role: factDraft.role,
525
+ role,
526
+ status,
514
527
  typeName: factDraft.typeName,
515
528
  at: factDraft.at,
516
529
  atText: factDraft.atText,
@@ -747,7 +760,7 @@ export class Soul {
747
760
  // More sources means more observations behind the line, so it ranks first.
748
761
  confidence: Math.min(0.95, 0.6 + 0.05 * line.sources.length),
749
762
  slots: [
750
- ...line.sources.map((id) => ({ slot: '来源', value: { kind: 'fact', factId: id } })),
763
+ ...line.sources.map((id) => ({ slot: '证据', value: { kind: 'fact', factId: id } })),
751
764
  // A merged line is not an original: re-merging merged lines loses detail.
752
765
  { slot: '合并', value: { kind: 'text', value: '1' } },
753
766
  { slot: '维度', value: { kind: 'text', value: line.dimension } },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Portable bundle: the whole record, a human-readable summary, and the injection text.
3
+ *
4
+ * Three layers, because they answer different questions (spec/vocabulary.v1 §7):
5
+ * archive — everything, including raw exchanges; the recipient can rebuild the store.
6
+ * summary — what a person can read in a minute: rules, profile, conclusions, promises,
7
+ * entities, open gaps. No ids unless they help.
8
+ * injection — what the model would see right now; the smallest useful transfer.
9
+ *
10
+ * Nothing here filters by importance. An export that silently drops "unimportant" rows
11
+ * cannot be the source of truth, and importance is a semantic judgement we do not store.
12
+ */
13
+ import type { PortableDump, PortableBundle } from './types.ts';
14
+ export declare function buildPortableSummary(dump: PortableDump, soulId: string, userId: string, now: number): string;
15
+ /** One JSON object per line, tagged so a reader can stream it without a schema. */
16
+ export declare function buildArchiveLines(dump: PortableDump): string[];
17
+ export declare function buildPortableBundle(dump: PortableDump, soulId: string, userId: string, injection: string, now: number): PortableBundle;
@@ -0,0 +1,93 @@
1
+ export function buildPortableSummary(dump, soulId, userId, now) {
2
+ const lines = [];
3
+ lines.push(`# soulbridge 档案 · ${userId}`);
4
+ lines.push('');
5
+ lines.push(`- soul: ${soulId}`);
6
+ lines.push(`- 导出时间: ${new Date(now).toISOString()}`);
7
+ lines.push(`- 规模: 事实 ${dump.facts.length} · 实体 ${dump.entities.length} · 线索 ${dump.threads.length} · 轮次 ${dump.turns.length} · 缺口 ${dump.gaps.length}`);
8
+ lines.push('');
9
+ const byRole = (role) => dump.facts.filter((fact) => fact.role === role && fact.status === 'active');
10
+ const slotText = (fact, name) => {
11
+ const slot = fact.slots.find((item) => item.slot === name);
12
+ return slot?.value.kind === 'text' ? slot.value.value : undefined;
13
+ };
14
+ const rules = byRole('rule');
15
+ if (rules.length > 0) {
16
+ lines.push(`## 规则(${rules.length})`);
17
+ for (const rule of rules) {
18
+ const trigger = slotText(rule, '触发');
19
+ const action = slotText(rule, '动作') ?? rule.statement;
20
+ lines.push(trigger ? `- 当 ${trigger}:${action}` : `- ${action}`);
21
+ }
22
+ lines.push('');
23
+ }
24
+ const profile = byRole('profile');
25
+ if (profile.length > 0) {
26
+ lines.push(`## 画像(${profile.length})`);
27
+ for (const line of profile)
28
+ lines.push(`- ${line.statement}`);
29
+ lines.push('');
30
+ }
31
+ const judgments = byRole('judgment');
32
+ if (judgments.length > 0) {
33
+ lines.push(`## 结论(${judgments.length})`);
34
+ for (const line of judgments.slice(0, 30))
35
+ lines.push(`- ${line.statement}`);
36
+ lines.push('');
37
+ }
38
+ const promises = byRole('promise');
39
+ if (promises.length > 0) {
40
+ lines.push(`## 承诺(${promises.length})`);
41
+ for (const line of promises)
42
+ lines.push(`- ${line.statement}`);
43
+ lines.push('');
44
+ }
45
+ if (dump.entities.length > 0) {
46
+ lines.push(`## 实体(${dump.entities.length})`);
47
+ for (const entity of dump.entities.slice(0, 50)) {
48
+ const aliases = entity.aliases.length > 0 ? `(别名:${entity.aliases.join('、')})` : '';
49
+ lines.push(`- ${entity.name} · ${entity.type}${aliases}`);
50
+ }
51
+ lines.push('');
52
+ }
53
+ const openGaps = dump.gaps.filter((gap) => gap.status === 'open');
54
+ if (openGaps.length > 0) {
55
+ lines.push(`## 未闭合的缺口(${openGaps.length})`);
56
+ for (const gap of openGaps.slice(0, 20))
57
+ lines.push(`- ${gap.topic}`);
58
+ lines.push('');
59
+ }
60
+ return lines.join('\n');
61
+ }
62
+ /** One JSON object per line, tagged so a reader can stream it without a schema. */
63
+ export function buildArchiveLines(dump) {
64
+ const lines = [];
65
+ // `record` is the discriminator, not `type`: Entity has its own `type` field, and a
66
+ // spread after the tag would silently overwrite it.
67
+ for (const entity of dump.entities)
68
+ lines.push(JSON.stringify({ record: 'entity', ...entity }));
69
+ for (const fact of dump.facts)
70
+ lines.push(JSON.stringify({ record: 'fact', ...fact }));
71
+ for (const thread of dump.threads)
72
+ lines.push(JSON.stringify({ record: 'thread', ...thread }));
73
+ for (const gap of dump.gaps)
74
+ lines.push(JSON.stringify({ record: 'gap', ...gap }));
75
+ for (const turn of dump.turns)
76
+ lines.push(JSON.stringify({ record: 'turn', ...turn }));
77
+ for (const step of dump.steps)
78
+ lines.push(JSON.stringify({ record: 'step', ...step }));
79
+ for (const correction of dump.corrections)
80
+ lines.push(JSON.stringify({ record: 'correction', ...correction }));
81
+ return lines;
82
+ }
83
+ export function buildPortableBundle(dump, soulId, userId, injection, now) {
84
+ return {
85
+ schemaVersion: 1,
86
+ exportedAt: now,
87
+ soulId,
88
+ userId,
89
+ summary: buildPortableSummary(dump, soulId, userId, now),
90
+ injection,
91
+ archive: buildArchiveLines(dump),
92
+ };
93
+ }
@@ -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');
@@ -631,6 +646,45 @@ export function openSqlite(file) {
631
646
  deviceId: row.device_id == null ? undefined : String(row.device_id),
632
647
  }));
633
648
  },
649
+ corrections(soulId, userId, limit = 100_000) {
650
+ return this.recentCorrections(soulId, userId, limit);
651
+ },
652
+ dump(soulId, userId) {
653
+ const facts = this.facts({ soulId, userId, limit: 100_000 });
654
+ const entities = this.findEntities({ soulId, userId, limit: 100_000 });
655
+ const threads = db
656
+ .prepare('SELECT * FROM threads WHERE soul_id = ? AND user_id = ? ORDER BY opened_at ASC')
657
+ .all(soulId, userId).map(rowToThread);
658
+ const gaps = db
659
+ .prepare('SELECT * FROM gaps WHERE soul_id = ? AND user_id = ? ORDER BY opened_at ASC')
660
+ .all(soulId, userId).map(rowToGap);
661
+ const turns = db
662
+ .prepare('SELECT * FROM turns WHERE soul_id = ? AND user_id = ? ORDER BY created_at ASC')
663
+ .all(soulId, userId).map(rowToTurnPair);
664
+ const steps = db
665
+ .prepare(`SELECT s.* FROM steps s JOIN turns t ON t.id = s.turn_id
666
+ WHERE t.soul_id = ? AND t.user_id = ? ORDER BY s.turn_id, s.idx`)
667
+ .all(soulId, userId);
668
+ return {
669
+ entities,
670
+ facts,
671
+ threads,
672
+ gaps,
673
+ turns,
674
+ steps: steps.map((row) => ({
675
+ turnId: String(row.turn_id),
676
+ idx: Number(row.idx),
677
+ kind: row.kind,
678
+ text: row.text == null ? undefined : String(row.text),
679
+ toolName: row.tool_name == null ? undefined : String(row.tool_name),
680
+ toolArgsChars: row.tool_args_chars == null ? undefined : Number(row.tool_args_chars),
681
+ toolResultChars: row.tool_result_chars == null ? undefined : Number(row.tool_result_chars),
682
+ ok: row.ok == null ? undefined : Boolean(row.ok),
683
+ at: Number(row.at),
684
+ })),
685
+ corrections: this.recentCorrections(soulId, userId, 100_000),
686
+ };
687
+ },
634
688
  countCorrections(soulId, userId) {
635
689
  const row = db
636
690
  .prepare('SELECT COUNT(*) AS n FROM corrections WHERE soul_id = ? AND user_id = ?')
@@ -1006,12 +1060,19 @@ export function openSqlite(file) {
1006
1060
  const text = row ? String(row.user_text ?? '').trim() : '';
1007
1061
  return text === '' ? null : text;
1008
1062
  },
1009
- factsByTurns(turnIds) {
1063
+ factsByTurns(turnIds, options = {}) {
1010
1064
  if (turnIds.length === 0)
1011
1065
  return [];
1012
1066
  const placeholders = turnIds.map(() => '?').join(',');
1067
+ // Only live facts. A retracted statement must not become evidence for the next
1068
+ // consolidation; a candidate is held but unconfirmed, so it is evidence for nothing
1069
+ // except the promotion step that decides its role.
1070
+ const live = options.includeCandidates
1071
+ ? `f.status IN ('${FACT_STATUS.active}','${FACT_STATUS.candidate}')`
1072
+ : `f.status = '${FACT_STATUS.active}'`;
1013
1073
  const rows = db
1014
- .prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders}) ORDER BY f.created_at ASC`)
1074
+ .prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders})
1075
+ AND ${live} ORDER BY f.created_at ASC`)
1015
1076
  .all(...turnIds);
1016
1077
  const slots = slotsOfRows(db, rows.map((row) => String(row.id)));
1017
1078
  return rows.map((row) => rowToFact(row, slots));
@@ -2,7 +2,7 @@
2
2
  * The only port. Every added method is a storage engine's opinion leaking into
3
3
  * the domain, so the surface stays deliberately small.
4
4
  */
5
- import type { AffectSignal, Correction, Emotion, Entity, EntityQuery, EntitySpec, Fact, FactQuery, FactRole, FactSpec, FactStatus, Gap, GapSpec, Identity, Memory, QuerySpec, SessionSummary, SlotValue, TurnPair, TurnPairSpec, TurnFindResult, TurnFindSpec, TurnSpec, TurnStep, TurnThread, ThreadSpec } from './types.ts';
5
+ import type { AffectSignal, Correction, Emotion, Entity, EntityQuery, EntitySpec, Fact, FactQuery, FactRole, FactSpec, FactStatus, Gap, GapSpec, Identity, Memory, PortableDump, QuerySpec, SessionSummary, SlotValue, TurnPair, TurnPairSpec, TurnFindResult, TurnFindSpec, TurnSpec, TurnStep, TurnThread, ThreadSpec } from './types.ts';
6
6
  export interface SoulStore {
7
7
  ensureSoul(identity: Identity): void;
8
8
  identity(soulId: string): Identity | null;
@@ -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. */
@@ -98,6 +103,10 @@ export interface SoulStore {
98
103
  putGap(spec: GapSpec): Gap;
99
104
  openGaps(soulId: string, userId: string, limit?: number): Gap[];
100
105
  closeGap(id: string, closedByFactId?: string, at?: number): void;
106
+ /** Corrections the user made, newest first — the objective signal for behaviour changes. */
107
+ corrections(soulId: string, userId: string, limit?: number): Correction[];
108
+ /** Everything a portable bundle needs, scoped to one soul/user. */
109
+ dump(soulId: string, userId: string): PortableDump;
101
110
  /** Exchanges visible to `userId`; scored against `spec.query` when given. */
102
111
  recallTurnPairs(spec: TurnPairSpec): TurnPair[];
103
112
  countTurnPairs(soulId: string, userId: string, sessionId?: string): number;
@@ -61,6 +61,33 @@ export interface TurnPair {
61
61
  metadata: Record<string, unknown>;
62
62
  createdAt: number;
63
63
  }
64
+ /** One step of a turn, flattened for the archive. */
65
+ export interface PortableStep extends TurnStep {
66
+ turnId: string;
67
+ }
68
+ /** Everything the portable bundle needs, scoped to one soul/user. */
69
+ export interface PortableDump {
70
+ entities: Entity[];
71
+ facts: Fact[];
72
+ threads: TurnThread[];
73
+ gaps: Gap[];
74
+ turns: TurnPair[];
75
+ steps: PortableStep[];
76
+ corrections: Correction[];
77
+ }
78
+ /** The three layers of the portability contract (spec/vocabulary.v1 §7). */
79
+ export interface PortableBundle {
80
+ schemaVersion: number;
81
+ exportedAt: number;
82
+ soulId: string;
83
+ userId: string;
84
+ /** Human-readable: rules, profile, conclusions, promises, entities, open gaps. */
85
+ summary: string;
86
+ /** What the model would see right now. */
87
+ injection: string;
88
+ /** One JSON object per line, including raw turns — the source of truth. */
89
+ archive: string[];
90
+ }
64
91
  /** What a recall call may narrow by. Everything is optional. */
65
92
  export interface TurnPairSpec {
66
93
  soulId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.102",
3
+ "version": "0.0.104",
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",