soulbridge 0.0.103 → 0.0.105

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);
@@ -409,6 +427,41 @@ async function main() {
409
427
  out({ db: dbPath, ...soul.stats() });
410
428
  return;
411
429
  }
430
+ case 'why': {
431
+ // The reader for the 依据 slot: a fact I said must be traceable to the turn I said it
432
+ // in, and a conclusion must be traceable to the facts that support it.
433
+ const factId = positional[0];
434
+ if (!factId)
435
+ throw new Error('usage: soulbridge why <factId>');
436
+ const { soul } = open(flags);
437
+ const fact = soul.store.fact(factId);
438
+ if (!fact) {
439
+ out({ ok: false, error: `no fact ${factId}` });
440
+ return;
441
+ }
442
+ const basisSlot = fact.slots.find((slot) => slot.slot === '依据' && slot.value.kind === 'text');
443
+ const turn = basisSlot?.value.kind === 'text' ? soul.store.turn(basisSlot.value.value) : null;
444
+ out({
445
+ id: fact.id,
446
+ statement: fact.statement,
447
+ role: fact.role,
448
+ status: fact.status,
449
+ source: fact.source,
450
+ basis: turn
451
+ ? {
452
+ turnId: turn.id,
453
+ at: new Date(turn.at).toISOString(),
454
+ userText: turn.userText,
455
+ assistantText: turn.assistantText.slice(0, 200),
456
+ }
457
+ : null,
458
+ evidence: fact.slots
459
+ .filter((slot) => slot.slot === '证据' && slot.value.kind === 'fact')
460
+ .map((slot) => (slot.value.kind === 'fact' ? slot.value.factId : undefined))
461
+ .filter(Boolean),
462
+ });
463
+ return;
464
+ }
412
465
  case 'demo':
413
466
  await demo();
414
467
  return;
@@ -425,6 +478,8 @@ async function main() {
425
478
  ' recall <q> [--limit N] 召回',
426
479
  ' reflect --text|--file 会话反思',
427
480
  ' export --out <file> 导出 JSONL',
481
+ ' export --dir <directory> 导出可携带档案(archive/summary/injection)',
482
+ ' why <factId> 这条事实的依据:来源轮次 / 证据',
428
483
  ' import --in <file> 导入 JSONL',
429
484
  ' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
430
485
  ' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
@@ -78,13 +78,22 @@ export function buildContext(store, request, now = Date.now()) {
78
78
  .facts({ soulId: request.soulId, userId: request.userId, limit: 300 })
79
79
  .filter((fact) => isActive(fact) &&
80
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 }));
81
+ .map((fact) => ({
82
+ id: fact.id,
83
+ label: fact.typeName ?? '事实',
84
+ text: fact.statement,
85
+ score: fact.confidence,
86
+ // ADR 0035: my own side of the exchange is inferred and must not read like something
87
+ // the user said. The marker is two characters; a per-line sentence would double the
88
+ // injection, so the explanation is written once below the block.
89
+ inferred: fact.source === 'inferred',
90
+ }));
82
91
  for (const memory of store.query({ soulId: request.soulId, userId: request.userId })) {
83
92
  if (request.types !== undefined && !request.types.includes(memory.type))
84
93
  continue;
85
94
  if (request.minImportance !== undefined && memory.importance < request.minImportance)
86
95
  continue;
87
- anchors.push({ id: memory.id, label: memory.type, text: memory.content, score: memory.importance / 10 });
96
+ anchors.push({ id: memory.id, label: memory.type, text: memory.content, score: memory.importance / 10, inferred: false });
88
97
  }
89
98
  anchors.sort((a, b) => b.score - a.score);
90
99
  const maxItems = request.maxItems ?? Number.POSITIVE_INFINITY;
@@ -92,6 +101,7 @@ export function buildContext(store, request, now = Date.now()) {
92
101
  let dropped = 0;
93
102
  const lines = [];
94
103
  const used = [];
104
+ let sawInferred = false;
95
105
  for (const anchor of anchors) {
96
106
  if (lines.length >= maxItems) {
97
107
  dropped += 1;
@@ -99,7 +109,10 @@ export function buildContext(store, request, now = Date.now()) {
99
109
  }
100
110
  // Escaping can lengthen a line (one `&lt;` is four characters), so cost is charged
101
111
  // on the escaped text — the budget has to measure what actually gets injected.
102
- const line = `- [${escapeBlockText(anchor.label)}] ${escapeBlockText(anchor.text)}`;
112
+ const marker = anchor.inferred ? '我: ' : '';
113
+ const line = `- ${marker}[${escapeBlockText(anchor.label)}] ${escapeBlockText(anchor.text)}`;
114
+ if (anchor.inferred)
115
+ sawInferred = true;
103
116
  const cost = line.length + 1 + 10; // line + newline + section overhead share
104
117
  if (chars + cost > maxChars) {
105
118
  dropped += 1;
@@ -111,6 +124,9 @@ export function buildContext(store, request, now = Date.now()) {
111
124
  }
112
125
  if (request.includeAnchors !== false && lines.length > 0)
113
126
  sections.push(`<memories>\n${lines.join('\n')}\n</memories>`);
127
+ if (sawInferred) {
128
+ sections.push('<source-notes>以「我:」开头的是我上一轮说的话,未经用户确认;没标的都是用户说的。</source-notes>');
129
+ }
114
130
  const text = sections.join('\n\n');
115
131
  if (request.track !== false && used.length > 0)
116
132
  store.touch(used, now);
@@ -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
@@ -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
+ }
@@ -646,6 +646,45 @@ export function openSqlite(file) {
646
646
  deviceId: row.device_id == null ? undefined : String(row.device_id),
647
647
  }));
648
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
+ },
649
688
  countCorrections(soulId, userId) {
650
689
  const row = db
651
690
  .prepare('SELECT COUNT(*) AS n FROM corrections WHERE soul_id = ? AND user_id = ?')
@@ -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;
@@ -103,6 +103,10 @@ export interface SoulStore {
103
103
  putGap(spec: GapSpec): Gap;
104
104
  openGaps(soulId: string, userId: string, limit?: number): Gap[];
105
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;
106
110
  /** Exchanges visible to `userId`; scored against `spec.query` when given. */
107
111
  recallTurnPairs(spec: TurnPairSpec): TurnPair[];
108
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.103",
3
+ "version": "0.0.105",
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",