soulbridge 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -59,6 +59,7 @@ npx soulbridge init
59
59
  npx soulbridge remember --type strategy "提交前先跑 make check,能省一轮 CI"
60
60
  npx soulbridge context --query "要不要先跑测试"
61
61
  npx soulbridge affect "我不太开心,这个方案又被拒了"
62
+ npx soulbridge import --from AGENTS.md # 把已有的项目约定导进来
62
63
  npx soulbridge demo # 起一个 soul,跑完整 context→observe→reflect→context
63
64
  ```
64
65
 
@@ -88,6 +89,15 @@ SQLite(默认) / Postgres(可选)
88
89
 
89
90
  详见 [docs/architecture.md](docs/architecture.md)。
90
91
 
92
+ ## 接入宿主
93
+
94
+ | 宿主 | 方式 | 位置 |
95
+ |---|---|---|
96
+ | DSH | 把 skill 放进 skills 目录 | [`adapters/dsh/SKILL.md`](adapters/dsh/SKILL.md) |
97
+ | Claude Code / Pi / Cursor | 同理:把同一份 `SKILL.md` 当作指令文件 | v0.5+ |
98
+
99
+ 接上之后,宿主在会话开始运行 `soulbridge context`、每轮运行 `soulbridge observe`、结束时运行 `soulbridge ingest`。**拿不到会话转写也不影响使用**:`observe` 已经逐轮记录了观察。
100
+
91
101
  ## 设计文档
92
102
 
93
103
  | 文档 | 内容 |
@@ -95,11 +105,12 @@ SQLite(默认) / Postgres(可选)
95
105
  | [thesis](docs/thesis.md) | 核心命题、三条可证伪预测、四臂实验、放弃条件 |
96
106
  | [RFC 0001](docs/rfcs/0001-foundations.md) | 定位、用户、非目标、成功指标、四个动词、接入阶梯 |
97
107
  | [RFC 0002](docs/rfcs/0002-affect.md) | 输入侧情感层:两套情绪的边界、数据流、已知局限 |
98
- | [ADR 索引](docs/rfcs/README.md) | 21 条已生效的架构决定 |
108
+ | [ADR 索引](docs/rfcs/README.md) | 23 条已生效的架构决定 |
99
109
  | [protocol.v0](docs/spec/protocol.v0.md) | 四个动词的完整契约:字段、错误码、降级、测试映射 |
100
110
  | [affect.v0](docs/spec/affect.v0.md) | 词库格式、8 条规则、置信度公式、黄金集与消融 |
101
111
  | [storage.v0](docs/spec/storage.v0.md) | 路径、身份、迁移、并发、导出格式 |
102
112
  | [sync.v0](docs/spec/sync.v0.md) | 跨设备合并语义(v0.5 实现) |
113
+ | [transcript.v1](docs/spec/transcript.v1.md) | 可移植会话转写格式(ingest 的输入) |
103
114
  | [quality](docs/quality.md) | 质量模型、九条门禁、五条评审清单 |
104
115
  | [references](docs/references.md) | 外部资料的去留与过期判断 |
105
116
  | [runbook](docs/runbook.md) | 发版流程、维护操作、已知待办 |
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: soulbridge
3
+ description: "跨工具的人格与记忆层。会话开始时注入记忆与情绪,每轮后记录观察,会话结束时反思。当用户提到「记得我 / 上次 / 之前 / 我的习惯」,或需要跨会话连续性时使用。"
4
+ ---
5
+
6
+ # soulbridge
7
+
8
+ 你在一个有 `soulbridge` CLI 的机器上工作。它保存了这个用户跨工具的偏好、被纠正过的错、以及最近的表达状态。
9
+
10
+ ## 会话开始
11
+
12
+ 运行一次,把输出**原样**放到你的系统提示最前面(不要改写、不要总结):
13
+
14
+ ```bash
15
+ soulbridge context --query "<用户这次的第一句话>"
16
+ ```
17
+
18
+ 输出通常是 `<soul>…</soul>`、`<style>…</style>`、`<affect>…</affect>`、`<memories>…</memories>` 几段。
19
+
20
+ ## 每轮结束
21
+
22
+ 只对**用户的**消息运行:
23
+
24
+ ```bash
25
+ soulbridge observe "<用户这条消息的原文>"
26
+ ```
27
+
28
+ ## 会话结束(可选)
29
+
30
+ 如果你能拿到一份 `transcript.v1.jsonl`(见 `docs/spec/transcript.v1.md`),运行:
31
+
32
+ ```bash
33
+ soulbridge ingest --file <transcript.jsonl>
34
+ ```
35
+
36
+ 拿不到就不要强行导出:`observe` 已经记录了每轮观察,`reflect` 可以下次再补。
37
+
38
+ ## 规则(重要)
39
+
40
+ 1. **不要向用户转述注入内容**。不要说"我记得你说过……",除非它确实与当前任务相关。
41
+ 2. **注入只影响措辞,不影响事实判断**。`<affect>` 里的建议是语气提示,不是改变结论的理由。
42
+ 3. **`soulbridge` 失败不阻塞**。命令报错时忽略它,继续正常回答。
43
+ 4. **不要猜测记忆内容**。没有注入的记忆就是没有,不要编造"以前聊过"。
44
+ 5. **不要把它写进项目文件**。个人记忆属于本机 `~/.soulbridge/soul.db`,不是仓库的一部分。
45
+
46
+ ## 边界
47
+
48
+ - 不要修改 `~/.soulbridge/` 下的文件
49
+ - 不要 `export` 并把内容贴到聊天里(导出是给用户备份用的)
50
+ - 不确定时运行 `soulbridge --help`
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -24,7 +24,7 @@ process.on('warning', (warning) => {
24
24
  return;
25
25
  process.stderr.write(`${warning.name}: ${warning.message}\n`);
26
26
  });
27
- const { openSqlite, Soul, ensureConfig, resolveDbPath, analyzeAffect, validateLexicon } = await import('../../core/src/index.js');
27
+ const { openSqlite, Soul, ensureConfig, resolveDbPath, analyzeAffect, validateLexicon, parseSteering, normalizeForDedupe, parseTranscriptJsonl, } = await import('../../core/src/index.js');
28
28
  const { llmAnalyzerFromEnv } = await import('./llm-analyzer.js');
29
29
  function parse(argv) {
30
30
  const [cmd = 'help', ...rest] = argv;
@@ -232,9 +232,34 @@ async function main() {
232
232
  return;
233
233
  }
234
234
  case 'import': {
235
+ // Steering file (AGENTS.md / CLAUDE.md) → memories (ADR 0022).
236
+ if (flags.from) {
237
+ const file = resolve(flags.from);
238
+ const drafts = parseSteering(readFileSync(file, 'utf8'));
239
+ const { soul } = open(flags);
240
+ const dryRun = flags['dry-run'] === 'true';
241
+ const existing = new Set(soul.store
242
+ .query({ soulId: soul.soulId, userId: soul.userId, limit: 100_000 })
243
+ .map((m) => normalizeForDedupe(m.content)));
244
+ let written = 0;
245
+ let skipped = 0;
246
+ for (const draft of drafts) {
247
+ const key = normalizeForDedupe(draft.text);
248
+ if (!key || existing.has(key)) {
249
+ skipped += 1;
250
+ continue;
251
+ }
252
+ existing.add(key);
253
+ if (!dryRun)
254
+ soul.remember(draft.text, { type: draft.type, importance: draft.importance });
255
+ written += 1;
256
+ }
257
+ out({ ok: true, source: file, kind: 'steering', parsed: drafts.length, written, skipped, dryRun });
258
+ return;
259
+ }
235
260
  const file = flags.in;
236
261
  if (!file)
237
- throw new Error('usage: soulbridge import --in soulbridge.jsonl');
262
+ throw new Error('usage: soulbridge import --from <steering.md> | --in <export.jsonl>');
238
263
  const { soul, deviceId } = open(flags);
239
264
  let memories = 0;
240
265
  let affects = 0;
@@ -251,7 +276,31 @@ async function main() {
251
276
  affects += 1;
252
277
  }
253
278
  }
254
- out({ ok: true, memories, affects });
279
+ out({ ok: true, kind: 'jsonl', memories, affects });
280
+ return;
281
+ }
282
+ case 'ingest': {
283
+ const file = flags.file;
284
+ if (!file)
285
+ throw new Error('usage: soulbridge ingest --file transcript.jsonl [--dry-run]');
286
+ const { turns, skipped } = parseTranscriptJsonl(readFileSync(resolve(file), 'utf8'));
287
+ const { soul } = open(flags);
288
+ const dryRun = flags['dry-run'] === 'true';
289
+ if (!dryRun) {
290
+ for (const turn of turns) {
291
+ if (turn.role !== 'user')
292
+ continue;
293
+ const signal = await soul.analyze(turn.content);
294
+ try {
295
+ soul.store.saveAffect(soul.soulId, soul.userId, signal, turn.at ?? Date.now(), soul.deviceId);
296
+ }
297
+ catch {
298
+ // Evaluation data is best-effort; ingestion must still complete.
299
+ }
300
+ }
301
+ }
302
+ const reflected = dryRun ? null : await soul.reflect(turns);
303
+ out({ ok: true, file: resolve(file), turns: turns.length, skipped, reflected, dryRun });
255
304
  return;
256
305
  }
257
306
  case 'stats': {
@@ -276,6 +325,8 @@ async function main() {
276
325
  ' reflect --text|--file 会话反思',
277
326
  ' export --out <file> 导出 JSONL',
278
327
  ' import --in <file> 导入 JSONL',
328
+ ' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
329
+ ' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
279
330
  ' stats 看状态',
280
331
  ' demo 端到端演示(无需模型)',
281
332
  '',
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Optional LLM-backed affect analyzer (ADR 0015).
3
+ *
4
+ * Deliberately lives in the CLI package, not in core: core must stay pure and
5
+ * offline. Opt-in only; every failure is thrown so the caller can degrade to
6
+ * the lexicon analyzer. The raw text never leaves the machine unless the user
7
+ * configured an endpoint.
8
+ */
9
+ import type { AffectAnalyzer } from '../../core/src/index.ts';
10
+ export interface LlmAnalyzerOptions {
11
+ /** Base URL, e.g. http://localhost:11434/v1 (Ollama) or any OpenAI-compatible endpoint. */
12
+ url: string;
13
+ model: string;
14
+ apiKey?: string;
15
+ timeoutMs?: number;
16
+ /** Confidence is capped so a self-assured model cannot bypass the injection gate. */
17
+ maxConfidence?: number;
18
+ }
19
+ export declare function createLlmAnalyzer(options: LlmAnalyzerOptions): AffectAnalyzer;
20
+ /** Build the analyzer from env vars, or null when not configured. */
21
+ export declare function llmAnalyzerFromEnv(): AffectAnalyzer | null;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Rule-based affect analyzer (ADR 0016 budget: 8 rules, no narrow phrases).
3
+ *
4
+ * Rules implemented here:
5
+ * R1 lexicon hit R2 negation window (3 tokens)
6
+ * R3 degree window (2) R4 punctuation emphasis (!! ?? ……)
7
+ * R5 repeated characters R6 withdrawal phrases (abstain, never injected)
8
+ * R7 code fences skipped R8 very short input halves confidence
9
+ *
10
+ * Pure function: no I/O, no model, no globals besides a per-lexicon cache.
11
+ */
12
+ import type { AffectSignal } from '../types.ts';
13
+ import type { Lexicon } from './lexicon.ts';
14
+ export declare const MAX_INPUT_CHARS = 32000;
15
+ export declare const NEGATION_WINDOW = 3;
16
+ export declare const DEGREE_WINDOW = 2;
17
+ export declare class AffectBudgetError extends Error {
18
+ readonly code = "BUDGET_EXCEEDED";
19
+ constructor(length: number);
20
+ }
21
+ /** Full-width punctuation → ASCII, strip code fences and shell lines, drop emoji. */
22
+ export declare function normalizeForAffect(text: string): string;
23
+ /** Max forward match over the lexicon dictionary; punctuation and spaces are separators. */
24
+ export declare function tokenizeForAffect(text: string, dict: Set<string>, maxLen: number): string[];
25
+ export interface AnalyzeOptions {
26
+ lexicon?: Lexicon;
27
+ }
28
+ export declare function analyzeAffect(text: string, options?: AnalyzeOptions): AffectSignal;
29
+ /** Lexicon analyzer as a port implementation (ADR 0015). */
30
+ export declare const lexiconAnalyzer: {
31
+ id: string;
32
+ analyze(text: string): AffectSignal;
33
+ };
34
+ /** Empty signal helper so callers do not reach for the shared constant by accident. */
35
+ export declare function emptyAffect(analyzer?: string): AffectSignal;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Whitelisted style directives (ADR 0014).
3
+ *
4
+ * The prompt may only ever contain text from this catalog. There is no code
5
+ * path that turns free-form analysis into prose, because a wrong "you seem
6
+ * angry" is worse than saying nothing.
7
+ */
8
+ import type { AffectSignal } from '../types.ts';
9
+ export type DirectiveId = 'hedging' | 'fragmented' | 'negative_high' | 'positive_high' | 'vague_terms';
10
+ export interface Directive {
11
+ id: DirectiveId;
12
+ text: string;
13
+ }
14
+ export declare const DIRECTIVE_CATALOG: readonly Directive[];
15
+ /** Which catalog entries the signal justifies, in priority order, capped at `max`. */
16
+ export declare function selectDirectives(signal: AffectSignal, max?: number): Directive[];
@@ -0,0 +1,8 @@
1
+ export type { AffectAnalyzer } from './port.ts';
2
+ export { analyzeAffect, lexiconAnalyzer, emptyAffect, AffectBudgetError, normalizeForAffect, tokenizeForAffect, MAX_INPUT_CHARS, } from './analyze.ts';
3
+ export type { AnalyzeOptions } from './analyze.ts';
4
+ export { SEED_LEXICON } from './seed-lexicon.ts';
5
+ export { validateLexicon, DEGREE_DELTA, NEGATORS, ABSTAIN_TERMS } from './lexicon.ts';
6
+ export type { Lexicon, LexiconEntry, DegreeWords } from './lexicon.ts';
7
+ export { DIRECTIVE_CATALOG, selectDirectives } from './directives.ts';
8
+ export type { Directive, DirectiveId } from './directives.ts';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Lexicon contract (ADR 0013).
3
+ *
4
+ * We adopt the *structure* of the DUTIR taxonomy (7 categories, intensity 1-9,
5
+ * polarity) because structures are not copyrightable. We do NOT ship any word
6
+ * list derived from an academic lexicon: the seed list in seed-lexicon.ts is
7
+ * written for this project. Users may import their own lexicon and carry the
8
+ * licensing consequences themselves.
9
+ */
10
+ import type { AffectCategory, AffectPolarity } from '../types.ts';
11
+ export interface LexiconEntry {
12
+ category: AffectCategory;
13
+ /** 1..9 */
14
+ intensity: number;
15
+ polarity: AffectPolarity;
16
+ }
17
+ export interface DegreeWords {
18
+ weak: string[];
19
+ medium: string[];
20
+ strong: string[];
21
+ extreme: string[];
22
+ }
23
+ export interface Lexicon {
24
+ version: string;
25
+ lang: string;
26
+ entries: Record<string, LexiconEntry>;
27
+ fillers: string[];
28
+ hedges: string[];
29
+ vague: Record<string, string[]>;
30
+ degree: DegreeWords;
31
+ }
32
+ /** Multiplicative-ish adjustment applied to a hit's intensity. */
33
+ export declare const DEGREE_DELTA: Record<keyof DegreeWords, number>;
34
+ /** Words that flip polarity when they precede an emotion word. */
35
+ export declare const NEGATORS: string[];
36
+ /** Withdrawal phrases: no explicit emotion word, but the affect is negative and weak. */
37
+ export declare const ABSTAIN_TERMS: string[];
38
+ /**
39
+ * Validate an externally supplied lexicon. Returns human-readable problems;
40
+ * an empty array means the lexicon is usable.
41
+ */
42
+ export declare function validateLexicon(value: unknown): string[];
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The analyzer port (ADR 0015).
3
+ *
4
+ * Rules are the default; a small model can be plugged in without touching
5
+ * core. Async analyzers MUST be cancellable by timeout at the call site and
6
+ * MUST be degradable to the lexicon analyzer.
7
+ */
8
+ import type { AffectSignal } from '../types.ts';
9
+ export interface AffectAnalyzer {
10
+ readonly id: string;
11
+ analyze(text: string): AffectSignal | Promise<AffectSignal>;
12
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Rule registry (ADR 0016).
3
+ *
4
+ * The budget is part of the design: eight rules, no exceptions. New cases are
5
+ * not fixed by adding a ninth rule — that is the signal to move to the model
6
+ * adapter instead. Every rule listed here must have a test and an ablation
7
+ * number in spec/affect.v0.md.
8
+ */
9
+ export interface RuleDescriptor {
10
+ readonly id: string;
11
+ readonly name: string;
12
+ readonly summary: string;
13
+ }
14
+ export declare const RULES: readonly RuleDescriptor[];
15
+ export declare const RULE_BUDGET = 8;
16
+ export declare const SEED_ENTRY_BUDGET = 120;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Seed lexicon, written for this project (MIT).
3
+ *
4
+ * Hard budget (ADR 0016): <= 120 emotion entries. Growing this list is not a
5
+ * fix for a missing case; if the budget is exhausted, the next step is the
6
+ * model adapter, not more words.
7
+ *
8
+ * Structure follows the DUTIR taxonomy (7 categories, intensity 1-9); no word
9
+ * list from that resource is copied here (ADR 0013).
10
+ */
11
+ import type { Lexicon } from './lexicon.ts';
12
+ export declare const SEED_LEXICON: Lexicon;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Context assembly: turn structured state into the text a host agent injects.
3
+ *
4
+ * Three rules:
5
+ * - never cut a sentence in half (drop whole memories instead)
6
+ * - every injected memory id is reported in `used`, so injection is auditable
7
+ * - the affect section is opt-in by confidence, capped in size, and only ever
8
+ * contains whitelisted sentences (ADR 0014)
9
+ */
10
+ import type { ContextRequest, ContextResult, Emotion } from './types.ts';
11
+ import type { SoulStore } from './store.ts';
12
+ export declare const DEFAULT_MAX_CHARS = 1200;
13
+ /** Characters reserved for the affect section; if it does not fit, it is dropped whole. */
14
+ export declare const AFFECT_BUDGET = 160;
15
+ /** Only the most recent user turn counts; older samples are for evaluation, not injection. */
16
+ export declare const AFFECT_WINDOW_MS: number;
17
+ /** Emotion → concrete behavioural instruction. Values are -2..+2. */
18
+ export declare function styleDirectives(emotion: Emotion): string[];
19
+ export declare function buildContext(store: SoulStore, request: ContextRequest, now?: number): ContextResult;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * soulbridge core — the personality layer.
3
+ *
4
+ * Public surface is deliberately tiny: a facade (`Soul`) over four verbs.
5
+ * Anything a host needs beyond this belongs in an adapter.
6
+ */
7
+ export * from './types.ts';
8
+ export type { SoulStore } from './store.ts';
9
+ export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError } from './sqlite.ts';
10
+ export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.ts';
11
+ export { reflect, ruleExtract, nextEmotion } from './reflect.ts';
12
+ export { rank, scoreMemory, tokenize, relevance } from './scoring.ts';
13
+ export * from './affect/index.ts';
14
+ export { measureStyle } from './style/measure.ts';
15
+ export type { StyleMetrics } from './style/measure.ts';
16
+ export * from './storage/config.ts';
17
+ export * from './storage/merge.ts';
18
+ export * from './storage/steering.ts';
19
+ export * from './storage/transcript.ts';
20
+ import type { AffectSignal, ContextRequest, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, Visibility } from './types.ts';
21
+ import type { SoulStore } from './store.ts';
22
+ import type { AffectAnalyzer } from './affect/port.ts';
23
+ export interface SoulOptions {
24
+ store: SoulStore;
25
+ soulId: string;
26
+ userId: string;
27
+ name?: string;
28
+ /** When the soul was born. Defaults to now; backdate it to demo long relationships. */
29
+ createdAt?: number;
30
+ /** Device identity for sync bookkeeping (ADR 0021). Optional in local use. */
31
+ deviceId?: string;
32
+ /** Defaults to the lexicon analyzer; pass an LLM-backed one to opt in (ADR 0015). */
33
+ analyzer?: AffectAnalyzer;
34
+ }
35
+ export declare class Soul {
36
+ readonly store: SoulStore;
37
+ readonly soulId: string;
38
+ readonly userId: string;
39
+ readonly name: string;
40
+ readonly deviceId: string;
41
+ readonly analyzer: AffectAnalyzer;
42
+ constructor(options: SoulOptions);
43
+ /** Verb 1: session start. */
44
+ context(request?: Partial<ContextRequest>): ContextResult;
45
+ /** Verb 3: on demand. */
46
+ recall(query: string, limit?: number): ScoredMemory[];
47
+ /** Pure analysis, no storage. Async because the analyzer may call a model. */
48
+ analyze(text: string): Promise<AffectSignal>;
49
+ /**
50
+ * Verb 2: after each turn.
51
+ *
52
+ * The analyzer may be async (a model); any failure degrades to the lexicon
53
+ * analyzer. Emotion is computed from the turn only — never from affect.
54
+ */
55
+ observe(turn: Turn): Promise<ObserveResult>;
56
+ /**
57
+ * Explicit form of `observe`: the host decided this is worth keeping.
58
+ * Same write path, no extraction heuristics.
59
+ */
60
+ remember(content: string, options?: {
61
+ type?: MemoryType;
62
+ importance?: number;
63
+ visibility?: Visibility;
64
+ }): string;
65
+ /** Verb 4: session end. */
66
+ reflect(turns: Turn[], llm?: LlmFn): Promise<ReflectResult>;
67
+ stats(): {
68
+ memories: number;
69
+ emotion: Emotion;
70
+ name: string;
71
+ };
72
+ private analyzeTurn;
73
+ }
@@ -13,6 +13,8 @@ export * from './affect/index.js';
13
13
  export { measureStyle } from './style/measure.js';
14
14
  export * from './storage/config.js';
15
15
  export * from './storage/merge.js';
16
+ export * from './storage/steering.js';
17
+ export * from './storage/transcript.js';
16
18
  import { buildContext } from './context.js';
17
19
  import { reflect, ruleExtract, nextEmotion } from './reflect.js';
18
20
  import { rank, tokenize } from './scoring.js';
@@ -55,14 +57,9 @@ export class Soul {
55
57
  this.store.touch(ranked.map((r) => r.memory.id));
56
58
  return ranked;
57
59
  }
58
- /** Pure analysis, no storage: useful for the CLI and for tests. */
59
- analyze(text) {
60
- try {
61
- return this.analyzer.analyze(text);
62
- }
63
- catch {
64
- return analyzeAffect(text);
65
- }
60
+ /** Pure analysis, no storage. Async because the analyzer may call a model. */
61
+ async analyze(text) {
62
+ return this.analyzeTurn(text);
66
63
  }
67
64
  /**
68
65
  * Verb 2: after each turn.
@@ -0,0 +1,20 @@
1
+ import type { Emotion, LlmFn, MemoryType, ReflectResult, Turn } from './types.ts';
2
+ import type { SoulStore } from './store.ts';
3
+ interface Draft {
4
+ type: MemoryType;
5
+ content: string;
6
+ importance: number;
7
+ }
8
+ export declare function ruleExtract(turns: Turn[]): Draft[];
9
+ /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
10
+ export declare function nextEmotion(current: Emotion, turns: Turn[]): Emotion;
11
+ export interface ReflectOptions {
12
+ soulId: string;
13
+ userId: string;
14
+ turns: Turn[];
15
+ now?: number;
16
+ /** Reserved for richer extraction; unused in v0. */
17
+ llm?: LlmFn;
18
+ }
19
+ export declare function reflect(store: SoulStore, options: ReflectOptions): Promise<ReflectResult>;
20
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Retrieval scoring. Pure functions, no I/O.
3
+ *
4
+ * score = 0.35*relevance + 0.25*importance + 0.20*recency + 0.15*vividness + 0.05*recall
5
+ *
6
+ * The weights are a claim, not a truth: changing them changes behaviour, so they
7
+ * live here (one place) and are covered by tests.
8
+ */
9
+ import type { Memory } from './types.ts';
10
+ /** A memory's relevance halves every 30 days without being recalled. */
11
+ export declare const RECENCY_HALF_LIFE_DAYS = 30;
12
+ export declare const WEIGHTS: {
13
+ readonly relevance: 0.35;
14
+ readonly importance: 0.25;
15
+ readonly recency: 0.2;
16
+ readonly vividness: 0.15;
17
+ readonly recall: 0.05;
18
+ };
19
+ /**
20
+ * CJK text has no spaces, so single characters are too coarse and the whole
21
+ * sentence is too fine: use character bigrams. Latin runs become words.
22
+ */
23
+ export declare function tokenize(input: string): string[];
24
+ export declare function relevance(queryTokens: string[], memory: Memory): number;
25
+ export declare function recency(reference: number, now: number): number;
26
+ export declare function scoreMemory(memory: Memory, queryTokens: string[], now: number): number;
27
+ export declare function rank(memories: Memory[], query: string, now: number): Array<{
28
+ memory: Memory;
29
+ score: number;
30
+ }>;
@@ -0,0 +1,9 @@
1
+ import type { SoulStore } from './store.ts';
2
+ export declare const SCHEMA_VERSION = 2;
3
+ export declare const AFFECT_RETENTION_MS: number;
4
+ export declare class DbNewerThanBinaryError extends Error {
5
+ readonly code = "DB_NEWER_THAN_BINARY";
6
+ readonly dbVersion: number;
7
+ constructor(dbVersion: number);
8
+ }
9
+ export declare function openSqlite(file: string): SoulStore;
@@ -0,0 +1,22 @@
1
+ export interface SoulConfig {
2
+ soulId: string;
3
+ userId: string;
4
+ name: string;
5
+ deviceId: string;
6
+ }
7
+ export declare const DEFAULT_SOUL_ID = "xiaoling";
8
+ export declare const DEFAULT_USER_ID = "local";
9
+ export declare const DEFAULT_NAME = "\u5C0F\u7075";
10
+ export declare function soulbridgeHome(): string;
11
+ export declare function configPath(): string;
12
+ export declare function defaultDbPath(): string;
13
+ export declare function newDeviceId(): string;
14
+ /** DB location: explicit flag > env > home. Never the cwd. */
15
+ export declare function resolveDbPath(explicit?: string): string;
16
+ export declare function loadConfig(): SoulConfig | null;
17
+ export declare function saveConfig(config: SoulConfig): void;
18
+ /**
19
+ * Read the config, creating or completing it when needed.
20
+ * Env overrides win, but are not persisted (an override is per-process).
21
+ */
22
+ export declare function ensureConfig(overrides?: Partial<SoulConfig>): SoulConfig;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Merge semantics for cross-device sync (ADR 0021).
3
+ *
4
+ * Pure functions, no I/O: the transport (folder sync, git, manual export) is
5
+ * someone else's problem; this file only decides who wins.
6
+ *
7
+ * Rule: higher `updatedAt` wins; on a tie the lexicographically larger
8
+ * `device` wins, so merging is deterministic regardless of arrival order.
9
+ */
10
+ export interface SyncRecord {
11
+ id: string;
12
+ device: string;
13
+ at: number;
14
+ updatedAt: number;
15
+ deleted?: boolean;
16
+ }
17
+ export declare function pickLatest<T extends SyncRecord>(a: T, b: T): T;
18
+ /** Union by id, keeping tombstones (deletion must survive a merge). */
19
+ export declare function mergeRecords<T extends SyncRecord>(records: Iterable<T>): T[];
20
+ /** A record is visible only if the winning version is not a tombstone. */
21
+ export declare function isVisible(record: SyncRecord): boolean;
22
+ export declare function liveRecords<T extends SyncRecord>(records: Iterable<T>): T[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Steering-file import (ADR 0022).
3
+ *
4
+ * AGENTS.md / CLAUDE.md are good at *shared, reviewed* conventions. We do not
5
+ * replace them; we read them once and turn the stable statements into
6
+ * memories that can travel across tools.
7
+ *
8
+ * Parsing is deliberately conservative: bullets and short standalone lines
9
+ * only. Code fences, tables, badges and links are skipped — importing a
10
+ * hundred lines of YAML is worse than importing nothing.
11
+ */
12
+ import type { MemoryType } from '../types.ts';
13
+ export interface SteeringDraft {
14
+ text: string;
15
+ type: MemoryType;
16
+ importance: number;
17
+ line: number;
18
+ }
19
+ export declare const STEERING_MAX_DRAFTS = 200;
20
+ /** Lowercase, strip punctuation and whitespace — for duplicate detection only. */
21
+ export declare function normalizeForDedupe(text: string): string;
22
+ /**
23
+ * Extract memory candidates from a steering file.
24
+ * Headings are dropped on purpose: they carry structure, not statements.
25
+ */
26
+ export declare function parseSteering(text: string): SteeringDraft[];
@@ -0,0 +1,83 @@
1
+ export const STEERING_MAX_DRAFTS = 200;
2
+ const MIN_LENGTH = 8;
3
+ const MAX_LENGTH = 240;
4
+ /** Lowercase, strip punctuation and whitespace — for duplicate detection only. */
5
+ export function normalizeForDedupe(text) {
6
+ return text
7
+ .toLowerCase()
8
+ .replace(/[\s\p{P}\p{S}]/gu, '')
9
+ .slice(0, 400);
10
+ }
11
+ function classify(text) {
12
+ // Preference first: "我喜欢 X,不要 Y" is a trait, not a rule.
13
+ if (/(我喜欢|偏好|习惯|倾向|prefer|i like)/i.test(text)) {
14
+ return { type: 'trait', importance: 6 };
15
+ }
16
+ if (/(必须|禁止|不要|务必|一律|每次|never|must|always|shall)/i.test(text)) {
17
+ return { type: 'strategy', importance: 8 };
18
+ }
19
+ if (/(项目|仓库|目录|技术栈|使用|基于|project|repo|stack)/i.test(text)) {
20
+ return { type: 'fact', importance: 6 };
21
+ }
22
+ return { type: 'fact', importance: 5 };
23
+ }
24
+ function stripFences(text) {
25
+ return text.replace(/```[\s\S]*?```/g, '');
26
+ }
27
+ function clean(line) {
28
+ return line
29
+ .replace(/^[-*+]\s+/, '')
30
+ .replace(/^\d+[.)]\s+/, '')
31
+ .replace(/^\[[ xX]\]\s+/, '')
32
+ .replace(/\*\*|__|`/g, '')
33
+ .trim();
34
+ }
35
+ function isSkippable(line) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ return true;
39
+ if (/^#{1,6}\s/.test(trimmed))
40
+ return true;
41
+ if (/^\|/.test(trimmed))
42
+ return true;
43
+ if (/^!\[/.test(trimmed))
44
+ return true;
45
+ if (/^<[^>]+>$/.test(trimmed))
46
+ return true;
47
+ if (/^https?:\/\//i.test(trimmed))
48
+ return true;
49
+ if (/^[-*_]{3,}$/.test(trimmed))
50
+ return true;
51
+ return false;
52
+ }
53
+ /**
54
+ * Extract memory candidates from a steering file.
55
+ * Headings are dropped on purpose: they carry structure, not statements.
56
+ */
57
+ export function parseSteering(text) {
58
+ const body = stripFences(text);
59
+ const drafts = [];
60
+ const seen = new Set();
61
+ body.split(/\r?\n/).forEach((raw, index) => {
62
+ if (isSkippable(raw))
63
+ return;
64
+ const bullet = /^\s*(?:[-*+]|\d+[.)]|\[[ xX]\])\s+/.test(raw);
65
+ const content = clean(raw);
66
+ // Bullets are intentional statements; free paragraphs need more substance.
67
+ const minLength = bullet ? MIN_LENGTH : 12;
68
+ if (content.length < minLength || content.length > MAX_LENGTH)
69
+ return;
70
+ // Paragraphs are noisier than bullets; require a statement-like shape.
71
+ if (!bullet && !/[。.!!??:]$/.test(content))
72
+ return;
73
+ if (!bullet && !/[\u4e00-\u9fa5]/.test(content) && content.split(/\s+/).length < 4)
74
+ return;
75
+ const key = normalizeForDedupe(content);
76
+ if (!key || seen.has(key))
77
+ return;
78
+ seen.add(key);
79
+ const { type, importance } = classify(content);
80
+ drafts.push({ text: content, type, importance, line: index + 1 });
81
+ });
82
+ return drafts.slice(0, STEERING_MAX_DRAFTS);
83
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Portable transcript ingestion (ADR 0023).
3
+ *
4
+ * Host session stores are private and change shape without notice (the DSH
5
+ * store is a multi-frame zstd container whose later frames are not decodable
6
+ * with a standard decompressor). Rather than reverse-engineering it, the
7
+ * adapter contract is a plain JSONL file that any host can export.
8
+ *
9
+ * Accepted line shapes (first match wins):
10
+ * {"role":"user","content":"…"}
11
+ * {"type":"user","text":"…"}
12
+ * {"message":{"role":"user","content":"…"}}
13
+ * {"role":"user","content":[{"type":"text","text":"…"}]}
14
+ *
15
+ * Everything else (headers, tool calls, tool results, reasoning) is skipped.
16
+ */
17
+ import type { Turn } from '../types.ts';
18
+ export interface TranscriptParseResult {
19
+ turns: Turn[];
20
+ skipped: number;
21
+ }
22
+ export declare function parseTranscriptJsonl(text: string): TranscriptParseResult;
@@ -0,0 +1,70 @@
1
+ const MAX_TURNS = 5000;
2
+ const MAX_CONTENT = 20_000;
3
+ function textOf(content) {
4
+ if (typeof content === 'string')
5
+ return content;
6
+ if (Array.isArray(content)) {
7
+ return content
8
+ .map((part) => {
9
+ if (typeof part === 'string')
10
+ return part;
11
+ if (part && typeof part === 'object') {
12
+ const p = part;
13
+ if (typeof p.text === 'string')
14
+ return p.text;
15
+ }
16
+ return '';
17
+ })
18
+ .join('\n');
19
+ }
20
+ return '';
21
+ }
22
+ function roleOf(record) {
23
+ const nested = record.message && typeof record.message === 'object'
24
+ ? record.message
25
+ : null;
26
+ const role = record.role ?? nested?.role ?? record.type;
27
+ if (role === 'user' || role === 'human')
28
+ return 'user';
29
+ if (role === 'assistant' || role === 'ai' || role === 'model')
30
+ return 'assistant';
31
+ return null;
32
+ }
33
+ export function parseTranscriptJsonl(text) {
34
+ const turns = [];
35
+ let skipped = 0;
36
+ for (const line of text.split(/\r?\n/)) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed)
39
+ continue;
40
+ let record;
41
+ try {
42
+ record = JSON.parse(trimmed);
43
+ }
44
+ catch {
45
+ skipped += 1;
46
+ continue;
47
+ }
48
+ const role = roleOf(record);
49
+ if (!role) {
50
+ skipped += 1;
51
+ continue;
52
+ }
53
+ const nested = record.message && typeof record.message === 'object'
54
+ ? record.message
55
+ : null;
56
+ const content = textOf(record.content ?? record.text ?? nested?.content).trim();
57
+ if (!content) {
58
+ skipped += 1;
59
+ continue;
60
+ }
61
+ const at = typeof record.at === 'number' ? record.at : undefined;
62
+ const last = turns.at(-1);
63
+ if (last && last.role === role && last.content === content)
64
+ continue;
65
+ turns.push({ role, content: content.slice(0, MAX_CONTENT), ...(at === undefined ? {} : { at }) });
66
+ if (turns.length >= MAX_TURNS)
67
+ break;
68
+ }
69
+ return { turns, skipped };
70
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The only port. Every added method is a storage engine's opinion leaking into
3
+ * the domain, so the surface stays deliberately small.
4
+ */
5
+ import type { AffectSignal, Emotion, Identity, Memory, QuerySpec } from './types.ts';
6
+ export interface SoulStore {
7
+ ensureSoul(identity: Identity): void;
8
+ identity(soulId: string): Identity | null;
9
+ emotion(soulId: string, userId: string): Emotion;
10
+ saveEmotion(soulId: string, userId: string, emotion: Emotion, now?: number): void;
11
+ put(memory: Memory): void;
12
+ /** Active, non-deleted memories visible to `userId` (own private + shared). */
13
+ query(spec: QuerySpec): Memory[];
14
+ /** Recall bookkeeping: recallCount++, lastRecalledAt = now. */
15
+ touch(ids: string[], now?: number): void;
16
+ /** Persist one affect sample (payload only; no raw text). */
17
+ saveAffect(soulId: string, userId: string, signal: AffectSignal, at: number, deviceId?: string): void;
18
+ /** Samples newer than `sinceMs`, newest first. */
19
+ recentAffect(soulId: string, userId: string, sinceMs: number): AffectSignal[];
20
+ /** Drop samples older than `beforeMs`; returns rows removed. */
21
+ purgeAffect(beforeMs: number): number;
22
+ count(soulId: string, userId: string): number;
23
+ close(): void;
24
+ }
@@ -0,0 +1,12 @@
1
+ import type { Lexicon } from '../affect/lexicon.ts';
2
+ export interface StyleMetrics {
3
+ /** Characters in the raw reply. */
4
+ length: number;
5
+ /** Hedge words per 100 characters. */
6
+ hedgeRate: number;
7
+ /** Question marks (full-width normalised). */
8
+ questions: number;
9
+ /** Whether the reply opens by restating or confirming the request. */
10
+ confirmFirst: boolean;
11
+ }
12
+ export declare function measureStyle(text: string, lexicon?: Lexicon): StyleMetrics;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Domain model. Pure types + constants, no logic, no dependencies.
3
+ *
4
+ * Rule: if a field only makes sense for one storage engine, it does not belong here.
5
+ * (No `vector`, no `embeddingId`, no `rowid`.)
6
+ */
7
+ export type MemoryType = 'semantic' | 'episodic' | 'strategy' | 'cognition' | 'relationship' | 'trait' | 'fact';
8
+ export type Visibility = 'private' | 'shared';
9
+ export type MemoryStatus = 'active' | 'superseded' | 'archived';
10
+ export interface Memory {
11
+ id: string;
12
+ soulId: string;
13
+ userId: string;
14
+ type: MemoryType;
15
+ content: string;
16
+ /** 1..10. How much this is worth remembering at all. */
17
+ importance: number;
18
+ /** 0..1. Decays over time; a vivid memory surfaces more easily. */
19
+ vividness: number;
20
+ recallCount: number;
21
+ status: MemoryStatus;
22
+ visibility: Visibility;
23
+ keywords: string[];
24
+ createdAt: number;
25
+ lastRecalledAt?: number;
26
+ validFrom?: number;
27
+ validUntil?: number;
28
+ supersededBy?: string;
29
+ /** Last-writer-wins clock for cross-device merge (ADR 0021). */
30
+ updatedAt?: number;
31
+ /** Tombstone; deletion is never physical on the first pass. */
32
+ deletedAt?: number;
33
+ }
34
+ export interface Identity {
35
+ soulId: string;
36
+ name: string;
37
+ creator?: string;
38
+ createdAt: number;
39
+ }
40
+ /** Six dimensions, each -2..+2. Baseline is slightly positive. */
41
+ export interface Emotion {
42
+ passion: number;
43
+ bond: number;
44
+ tension: number;
45
+ selfRegard: number;
46
+ curiosity: number;
47
+ contentment: number;
48
+ }
49
+ export declare const DEFAULT_EMOTION: Emotion;
50
+ export interface Turn {
51
+ role: 'user' | 'assistant';
52
+ content: string;
53
+ at?: number;
54
+ }
55
+ export interface ContextRequest {
56
+ soulId: string;
57
+ userId: string;
58
+ query?: string;
59
+ maxChars?: number;
60
+ }
61
+ export interface ContextResult {
62
+ text: string;
63
+ used: string[];
64
+ dropped: number;
65
+ chars: number;
66
+ /** Present only when the affect section was actually injected. */
67
+ affect?: {
68
+ confidence: number;
69
+ directives: string[];
70
+ };
71
+ }
72
+ export interface RecallRequest {
73
+ soulId: string;
74
+ userId: string;
75
+ q: string;
76
+ limit?: number;
77
+ }
78
+ export interface ScoredMemory {
79
+ memory: Memory;
80
+ score: number;
81
+ }
82
+ export interface QuerySpec {
83
+ soulId: string;
84
+ userId: string;
85
+ status?: MemoryStatus[];
86
+ types?: MemoryType[];
87
+ limit?: number;
88
+ }
89
+ export interface ReflectResult {
90
+ memories: number;
91
+ superseded: number;
92
+ emotion: Emotion;
93
+ }
94
+ export interface ObserveResult {
95
+ written: number;
96
+ emotion: Emotion;
97
+ /** Analysis of the user turn. Empty signal for assistant turns or on failure. */
98
+ affect: AffectSignal;
99
+ }
100
+ /** Injected by the caller. `core` never imports a model SDK. */
101
+ export type LlmFn = (prompt: string) => Promise<string> | string;
102
+ export type AffectCategory = 'joy' | 'anger' | 'sadness' | 'fear' | 'disgust' | 'surprise';
103
+ export type AffectPolarity = 'positive' | 'negative' | 'neutral';
104
+ export interface AffectHit {
105
+ term: string;
106
+ category: AffectCategory;
107
+ /** 1..9, already adjusted by degree modifiers. */
108
+ intensity: number;
109
+ polarity: AffectPolarity;
110
+ negated: boolean;
111
+ position: number;
112
+ }
113
+ export interface AffectFeatures {
114
+ fillers: number;
115
+ hedges: number;
116
+ vague: number;
117
+ tokens: number;
118
+ /** meaningful tokens / all tokens, 0..1 */
119
+ density: number;
120
+ }
121
+ export interface AffectSignal {
122
+ /** Which analyzer produced this: 'lexicon' | 'llm' | 'none'. */
123
+ analyzer: string;
124
+ hits: AffectHit[];
125
+ dominant: AffectCategory | null;
126
+ intensity: number;
127
+ valence: number;
128
+ arousal: number;
129
+ features: AffectFeatures;
130
+ confidence: number;
131
+ /** True when the text looks like withdrawal ("行吧", "随便") but no explicit emotion word. */
132
+ abstain: boolean;
133
+ }
134
+ export declare const EMPTY_AFFECT: AffectSignal;
135
+ /** Wire version of the four verbs. Additive changes bump MINOR (ADR 0005). */
136
+ export declare const PROTOCOL_VERSION = "0.2";
package/package.json CHANGED
@@ -1,49 +1,59 @@
1
1
  {
2
- "name": "soulbridge",
3
- "version": "0.0.4",
4
- "description": "A personality layer for any agent: continuity, forgetting, and growth across tools.",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "bin": {
8
- "soulbridge": "dist/packages/cli/src/cli.js"
9
- },
10
- "files": [
11
- "dist",
12
- "README.md",
13
- "LICENSE",
14
- "NOTICE"
15
- ],
16
- "workspaces": [
17
- "packages/*"
18
- ],
19
- "engines": {
20
- "node": ">=22.18"
21
- },
22
- "repository": {
23
- "type": "git",
24
- "url": "git+https://github.com/Jason0826/soulbridge.git"
25
- },
26
- "homepage": "https://github.com/Jason0826/soulbridge#readme",
27
- "bugs": {
28
- "url": "https://github.com/Jason0826/soulbridge/issues"
29
- },
30
- "keywords": [
31
- "ai-agent",
32
- "memory",
33
- "personality",
34
- "continuity",
35
- "mcp",
36
- "claude-code"
37
- ],
38
- "scripts": {
39
- "build": "tsc -p tsconfig.build.json",
40
- "test": "node --test \"packages/core/test/*.test.ts\"",
41
- "demo": "node packages/cli/src/cli.ts demo",
42
- "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "npm run build && npm test"
44
- },
45
- "devDependencies": {
46
- "@types/node": "^26.6.5",
47
- "typescript": "^7.0.2"
48
- }
2
+ "name": "soulbridge",
3
+ "version": "0.0.6",
4
+ "description": "A personality layer for any agent: continuity, forgetting, and growth across tools.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/packages/core/src/index.js",
8
+ "types": "./dist/packages/core/src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/packages/core/src/index.d.ts",
12
+ "import": "./dist/packages/core/src/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "bin": {
17
+ "soulbridge": "dist/packages/cli/src/cli.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "adapters",
22
+ "README.md",
23
+ "LICENSE",
24
+ "NOTICE"
25
+ ],
26
+ "workspaces": [
27
+ "packages/*"
28
+ ],
29
+ "engines": {
30
+ "node": "\u003e=22.18"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Jason0826/soulbridge.git"
35
+ },
36
+ "homepage": "https://github.com/Jason0826/soulbridge#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/Jason0826/soulbridge/issues"
39
+ },
40
+ "keywords": [
41
+ "ai-agent",
42
+ "memory",
43
+ "personality",
44
+ "continuity",
45
+ "mcp",
46
+ "claude-code"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "test": "node --test \"packages/core/test/*.test.ts\"",
51
+ "demo": "node packages/cli/src/cli.ts demo",
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "npm run build \u0026\u0026 npm test"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^26.6.5",
57
+ "typescript": "^7.0.2"
58
+ }
49
59
  }