soulbridge 0.0.103 → 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> 会话转写 → 记忆与情绪采样',
@@ -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.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",