soulbridge 0.0.12 → 0.0.14

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
@@ -114,6 +114,7 @@ SQLite(默认) / Postgres(可选)
114
114
  | [适配器索引](adapters/README.md) | 各宿主适配器与接入契约 |
115
115
  | [quality](docs/quality.md) | 质量模型、九条门禁、五条评审清单 |
116
116
  | [references](docs/references.md) | 外部资料的去留与过期判断 |
117
+ | [roadmap](docs/roadmap.md) | 下一步做什么、借鉴了什么、明确不做什么 |
117
118
  | [runbook](docs/runbook.md) | 发版流程、维护操作、已知待办 |
118
119
 
119
120
  **规则**:架构级改动必须先有 RFC 或 ADR;聊天里达成的不算决定。
@@ -324,6 +324,22 @@ async function main() {
324
324
  out({ ok: soul.forget(id), id });
325
325
  return;
326
326
  }
327
+ case 'corrections': {
328
+ const { soul } = open(flags);
329
+ const rows = soul.corrections(flags.limit ? Number(flags.limit) : 10);
330
+ if (rows.length === 0) {
331
+ process.stdout.write('no corrections recorded yet\n');
332
+ return;
333
+ }
334
+ for (const row of rows) {
335
+ const when = new Date(row.at).toISOString().slice(0, 19).replace('T', ' ');
336
+ process.stdout.write(`${when} [${row.rule ?? '-'}]\n`);
337
+ process.stdout.write(` 用户: ${row.userText}\n`);
338
+ if (row.assistantText)
339
+ process.stdout.write(` 当时: ${row.assistantText}\n`);
340
+ }
341
+ return;
342
+ }
327
343
  case 'status': {
328
344
  const config = ensureConfig();
329
345
  const dbPath = resolveDbPath(flags.db);
@@ -393,6 +409,7 @@ async function main() {
393
409
  ' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
394
410
  ' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
395
411
  ' list [--limit N] 看它记住了什么',
412
+ ' corrections [--limit N] 看被纠正过的证据',
396
413
  ' forget <id> 忘掉一条记忆(墓碑)',
397
414
  ' status 一眼看它是否在工作',
398
415
  ' stats 看原始状态(JSON)',
@@ -8,7 +8,7 @@ export * from './types.ts';
8
8
  export type { SoulStore } from './store.ts';
9
9
  export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError } from './sqlite.ts';
10
10
  export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.ts';
11
- export { reflect, ruleExtract, nextEmotion } from './reflect.ts';
11
+ export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule } from './reflect.ts';
12
12
  export { rank, scoreMemory, tokenize, relevance } from './scoring.ts';
13
13
  export * from './affect/index.ts';
14
14
  export { measureStyle } from './style/measure.ts';
@@ -17,7 +17,7 @@ export * from './storage/config.ts';
17
17
  export * from './storage/merge.ts';
18
18
  export * from './storage/steering.ts';
19
19
  export * from './storage/transcript.ts';
20
- import type { AffectSignal, ContextRequest, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, Visibility } from './types.ts';
20
+ import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, Visibility } from './types.ts';
21
21
  import type { SoulStore } from './store.ts';
22
22
  import type { AffectAnalyzer } from './affect/port.ts';
23
23
  export interface SoulOptions {
@@ -56,6 +56,16 @@ export declare class Soul {
56
56
  list(limit?: number): Memory[];
57
57
  /** Tombstone one memory so it never reaches a prompt again. */
58
58
  forget(id: string): boolean;
59
+ /**
60
+ * Record a user correction as evidence (RFC 0003 phase A). Nothing here
61
+ * reaches a prompt; distillation to a strategy is a later phase.
62
+ */
63
+ recordCorrection(input: {
64
+ userText: string;
65
+ assistantText?: string;
66
+ rule?: string;
67
+ }): string;
68
+ corrections(limit?: number): Correction[];
59
69
  /** Verb 3: on demand. */
60
70
  recall(query: string, limit?: number): ScoredMemory[];
61
71
  /** Pure analysis, no storage. Async because the analyzer may call a model. */
@@ -7,7 +7,7 @@
7
7
  export * from './types.js';
8
8
  export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError } from './sqlite.js';
9
9
  export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.js';
10
- export { reflect, ruleExtract, nextEmotion } from './reflect.js';
10
+ export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule } from './reflect.js';
11
11
  export { rank, scoreMemory, tokenize, relevance } from './scoring.js';
12
12
  export * from './affect/index.js';
13
13
  export { measureStyle } from './style/measure.js';
@@ -19,7 +19,7 @@ import { buildContext } from './context.js';
19
19
  import { reflect, ruleExtract, nextEmotion } from './reflect.js';
20
20
  import { rank, tokenize } from './scoring.js';
21
21
  import { analyzeAffect, emptyAffect, lexiconAnalyzer } from './affect/analyze.js';
22
- import { llmExtractTurn } from './reflect.js';
22
+ import { gateDrafts, llmExtractTurn } from './reflect.js';
23
23
  export class Soul {
24
24
  store;
25
25
  soulId;
@@ -67,6 +67,28 @@ export class Soul {
67
67
  forget(id) {
68
68
  return this.store.forget(id);
69
69
  }
70
+ /**
71
+ * Record a user correction as evidence (RFC 0003 phase A). Nothing here
72
+ * reaches a prompt; distillation to a strategy is a later phase.
73
+ */
74
+ recordCorrection(input) {
75
+ const now = Date.now();
76
+ const id = `cor_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
77
+ this.store.saveCorrection({
78
+ id,
79
+ soulId: this.soulId,
80
+ userId: this.userId,
81
+ at: now,
82
+ userText: input.userText,
83
+ assistantText: input.assistantText,
84
+ rule: input.rule,
85
+ deviceId: this.deviceId,
86
+ });
87
+ return id;
88
+ }
89
+ corrections(limit = 20) {
90
+ return this.store.recentCorrections(this.soulId, this.userId, limit);
91
+ }
70
92
  /** Verb 3: on demand. */
71
93
  recall(query, limit = 5) {
72
94
  const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
@@ -107,10 +129,13 @@ export class Soul {
107
129
  }
108
130
  }
109
131
  }
132
+ // Deterministic write gates: shape -> semantics -> per-turn budget. Whatever
133
+ // they refuse is reported, so written: 0 always has a reason.
134
+ const gated = gateDrafts(drafts);
110
135
  // observe runs every turn; repeating a sentence must not create a second row.
111
136
  const seen = new Set(this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 }).map((m) => m.content.trim()));
112
137
  let written = 0;
113
- for (const draft of drafts) {
138
+ for (const draft of gated.accepted) {
114
139
  if (seen.has(draft.content.trim()))
115
140
  continue;
116
141
  seen.add(draft.content.trim());
@@ -142,7 +167,14 @@ export class Soul {
142
167
  }
143
168
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
144
169
  this.store.saveEmotion(this.soulId, this.userId, emotion, now);
145
- return { written, emotion, affect, extractedBy: written > 0 ? extractedBy : 'none' };
170
+ return {
171
+ written,
172
+ committed: written > 0,
173
+ rejections: gated.rejected,
174
+ emotion,
175
+ affect,
176
+ extractedBy: written > 0 ? extractedBy : 'none',
177
+ };
146
178
  }
147
179
  /**
148
180
  * Explicit form of `observe`: the host decided this is worth keeping.
@@ -1,4 +1,4 @@
1
- import type { Emotion, LlmFn, MemoryType, ReflectResult, Turn } from './types.ts';
1
+ import type { DraftRejection, Emotion, LlmFn, MemoryType, ReflectResult, Turn } from './types.ts';
2
2
  import type { SoulStore } from './store.ts';
3
3
  interface Draft {
4
4
  type: MemoryType;
@@ -26,6 +26,24 @@ export declare const MAX_STRATEGY_CHARS = 80;
26
26
  export declare function distillStrategy(text: string): string | null;
27
27
  /** Cheap, synchronous, no model. Deliberately conservative: false positives cost trust. */
28
28
  export declare const RULES: Rule[];
29
+ /** Is this user turn a correction? Evidence for RFC 0003, not a memory by itself. */
30
+ export declare function isCorrection(text: string): boolean;
31
+ /** Which marker matched, for statistics; null when the turn is not a correction. */
32
+ export declare function correctionRule(text: string): string | null;
33
+ /** Types an extractor may produce; episodic/relationship are host-local. */
34
+ export declare const EXTRACTABLE_TYPES: MemoryType[];
35
+ export declare const MAX_CONTENT_CHARS = 500;
36
+ /** A single turn should not flood the store. */
37
+ export declare const MAX_DRAFTS_PER_TURN = 3;
38
+ /**
39
+ * Deterministic write gates. A draft must pass shape, then semantics, then the
40
+ * per-turn budget; anything refused is reported with the gate that refused it,
41
+ * so a "written: 0" is explainable rather than mysterious.
42
+ */
43
+ export declare function gateDrafts(drafts: Draft[]): {
44
+ accepted: Draft[];
45
+ rejected: DraftRejection[];
46
+ };
29
47
  export declare function ruleExtract(turns: Turn[]): Draft[];
30
48
  /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
31
49
  export declare function nextEmotion(current: Emotion, turns: Turn[]): Emotion;
@@ -75,8 +75,52 @@ export const RULES = [
75
75
  },
76
76
  ];
77
77
  const CORRECTION = /(不对|不是这样|错了|你搞错|wrong|that'?s not right|incorrect)/i;
78
+ /** Is this user turn a correction? Evidence for RFC 0003, not a memory by itself. */
79
+ export function isCorrection(text) {
80
+ return CORRECTION.test(text);
81
+ }
82
+ /** Which marker matched, for statistics; null when the turn is not a correction. */
83
+ export function correctionRule(text) {
84
+ const match = CORRECTION.exec(text);
85
+ return match ? match[0] : null;
86
+ }
78
87
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
79
88
  const round2 = (n) => Math.round(n * 100) / 100;
89
+ /** Types an extractor may produce; episodic/relationship are host-local. */
90
+ export const EXTRACTABLE_TYPES = ['strategy', 'trait', 'fact', 'cognition'];
91
+ export const MAX_CONTENT_CHARS = 500;
92
+ /** A single turn should not flood the store. */
93
+ export const MAX_DRAFTS_PER_TURN = 3;
94
+ /**
95
+ * Deterministic write gates. A draft must pass shape, then semantics, then the
96
+ * per-turn budget; anything refused is reported with the gate that refused it,
97
+ * so a "written: 0" is explainable rather than mysterious.
98
+ */
99
+ export function gateDrafts(drafts) {
100
+ const accepted = [];
101
+ const rejected = [];
102
+ for (const draft of drafts) {
103
+ const content = draft.content.trim();
104
+ if (!EXTRACTABLE_TYPES.includes(draft.type)) {
105
+ rejected.push({ gate: 'shape', reason: `type ${draft.type} is not extractable`, content });
106
+ continue;
107
+ }
108
+ if (content.length < 2 || content.length > MAX_CONTENT_CHARS) {
109
+ rejected.push({ gate: 'shape', reason: `length ${content.length} (2..${MAX_CONTENT_CHARS})`, content });
110
+ continue;
111
+ }
112
+ if (content.replace(/[\s\p{P}\p{S}]/gu, '').length < 2) {
113
+ rejected.push({ gate: 'semantic', reason: 'no word characters', content });
114
+ continue;
115
+ }
116
+ if (accepted.length >= MAX_DRAFTS_PER_TURN) {
117
+ rejected.push({ gate: 'budget', reason: `more than ${MAX_DRAFTS_PER_TURN} drafts in one turn`, content });
118
+ continue;
119
+ }
120
+ accepted.push({ ...draft, content });
121
+ }
122
+ return { accepted, rejected };
123
+ }
80
124
  export function ruleExtract(turns) {
81
125
  const drafts = [];
82
126
  for (const turn of turns) {
@@ -140,8 +184,9 @@ export async function reflect(store, options) {
140
184
  }
141
185
  const existing = store.query({ soulId: options.soulId, userId: options.userId });
142
186
  const seen = new Set(existing.map((m) => m.content));
187
+ const { accepted, rejected } = gateDrafts(drafts);
143
188
  let written = 0;
144
- for (const draft of drafts) {
189
+ for (const draft of accepted) {
145
190
  if (seen.has(draft.content))
146
191
  continue;
147
192
  seen.add(draft.content);
@@ -168,7 +213,7 @@ export async function reflect(store, options) {
168
213
  emotion = nextEmotion(emotion, options.turns);
169
214
  store.saveEmotion(options.soulId, options.userId, emotion);
170
215
  }
171
- return { memories: written, superseded: 0, emotion };
216
+ return { memories: written, superseded: null, emotion, committed: written > 0, rejections: rejected };
172
217
  }
173
218
  /* ------------------------------------------------------------------ *
174
219
  * LLM distillation port (optional)
@@ -76,6 +76,17 @@ CREATE TABLE IF NOT EXISTS affect_samples (
76
76
  );
77
77
  CREATE INDEX IF NOT EXISTS idx_affect ON affect_samples(soul_id, user_id, at DESC);
78
78
  CREATE UNIQUE INDEX IF NOT EXISTS idx_affect_unique ON affect_samples(soul_id, user_id, at, analyzer);
79
+ CREATE TABLE IF NOT EXISTS corrections (
80
+ id TEXT PRIMARY KEY,
81
+ soul_id TEXT NOT NULL,
82
+ user_id TEXT NOT NULL,
83
+ at INTEGER NOT NULL,
84
+ user_text TEXT NOT NULL,
85
+ assistant_text TEXT,
86
+ rule TEXT,
87
+ device_id TEXT
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_corrections ON corrections(soul_id, user_id, at DESC);
79
90
  `;
80
91
  const sleepSync = (ms) => {
81
92
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -123,6 +134,20 @@ function upgradeToV2(db) {
123
134
  db.exec('UPDATE memories SET updated_at = created_at WHERE updated_at IS NULL');
124
135
  db.exec('UPDATE emotions SET updated_at = 0 WHERE updated_at IS NULL');
125
136
  }
137
+ /** v2 → v3: corrections evidence table (RFC 0003 phase A). */
138
+ function upgradeToV3(db) {
139
+ db.exec(`CREATE TABLE IF NOT EXISTS corrections (
140
+ id TEXT PRIMARY KEY,
141
+ soul_id TEXT NOT NULL,
142
+ user_id TEXT NOT NULL,
143
+ at INTEGER NOT NULL,
144
+ user_text TEXT NOT NULL,
145
+ assistant_text TEXT,
146
+ rule TEXT,
147
+ device_id TEXT
148
+ )`);
149
+ db.exec('CREATE INDEX IF NOT EXISTS idx_corrections ON corrections(soul_id, user_id, at DESC)');
150
+ }
126
151
  function backupBeforeUpgrade(file, from) {
127
152
  if (file === ':memory:' || !existsSync(file))
128
153
  return;
@@ -144,6 +169,8 @@ export function openSqlite(file) {
144
169
  backupBeforeUpgrade(file, version);
145
170
  if (version < 2)
146
171
  upgradeToV2(db);
172
+ if (version < 3)
173
+ upgradeToV3(db);
147
174
  setVersion(db, SCHEMA_VERSION);
148
175
  }
149
176
  const stmtPut = db.prepare(`
@@ -260,6 +287,34 @@ export function openSqlite(file) {
260
287
  const result = withRetry(() => db.prepare('DELETE FROM affect_samples WHERE at < ?').run(beforeMs));
261
288
  return Number(result.changes ?? 0);
262
289
  },
290
+ saveCorrection(correction) {
291
+ withRetry(() => db
292
+ .prepare(`INSERT INTO corrections (id, soul_id, user_id, at, user_text, assistant_text, rule, device_id)
293
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
294
+ .run(correction.id, correction.soulId, correction.userId, correction.at, correction.userText, correction.assistantText ?? null, correction.rule ?? null, correction.deviceId ?? null));
295
+ },
296
+ recentCorrections(soulId, userId, limit) {
297
+ const rows = db
298
+ .prepare(`SELECT * FROM corrections WHERE soul_id = ? AND user_id = ?
299
+ ORDER BY at DESC LIMIT ?`)
300
+ .all(soulId, userId, limit);
301
+ return rows.map((row) => ({
302
+ id: String(row.id),
303
+ soulId: String(row.soul_id),
304
+ userId: String(row.user_id),
305
+ at: Number(row.at),
306
+ userText: String(row.user_text),
307
+ assistantText: row.assistant_text == null ? undefined : String(row.assistant_text),
308
+ rule: row.rule == null ? undefined : String(row.rule),
309
+ deviceId: row.device_id == null ? undefined : String(row.device_id),
310
+ }));
311
+ },
312
+ countCorrections(soulId, userId) {
313
+ const row = db
314
+ .prepare('SELECT COUNT(*) AS n FROM corrections WHERE soul_id = ? AND user_id = ?')
315
+ .get(soulId, userId);
316
+ return Number(row.n);
317
+ },
263
318
  count(soulId, userId) {
264
319
  const row = db
265
320
  .prepare(`SELECT COUNT(*) AS n FROM memories
@@ -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, Emotion, Identity, Memory, QuerySpec } from './types.ts';
5
+ import type { AffectSignal, Correction, Emotion, Identity, Memory, QuerySpec } from './types.ts';
6
6
  export interface SoulStore {
7
7
  ensureSoul(identity: Identity): void;
8
8
  identity(soulId: string): Identity | null;
@@ -24,6 +24,10 @@ export interface SoulStore {
24
24
  recentAffect(soulId: string, userId: string, sinceMs: number): AffectSignal[];
25
25
  /** Drop samples older than `beforeMs`; returns rows removed. */
26
26
  purgeAffect(beforeMs: number): number;
27
+ /** Evidence, not memory: a user correction plus the reply it targeted. */
28
+ saveCorrection(correction: Correction): void;
29
+ recentCorrections(soulId: string, userId: string, limit: number): Correction[];
30
+ countCorrections(soulId: string, userId: string): number;
27
31
  count(soulId: string, userId: string): number;
28
32
  close(): void;
29
33
  }
@@ -100,11 +100,30 @@ export interface QuerySpec {
100
100
  }
101
101
  export interface ReflectResult {
102
102
  memories: number;
103
- superseded: number;
103
+ /**
104
+ * How many older memories were replaced by a newer conflicting one.
105
+ * `null` means this build does not track supersession yet — it used to report
106
+ * a hardcoded 0, which claimed a capability that did not exist.
107
+ */
108
+ superseded: number | null;
104
109
  emotion: Emotion;
110
+ /** True when at least one memory was durably written by this call. */
111
+ committed: boolean;
112
+ /** Drafts refused before writing, with the gate that refused them. */
113
+ rejections: DraftRejection[];
114
+ }
115
+ export type GateName = 'shape' | 'semantic' | 'budget';
116
+ export interface DraftRejection {
117
+ gate: GateName;
118
+ reason: string;
119
+ content: string;
105
120
  }
106
121
  export interface ObserveResult {
107
122
  written: number;
123
+ /** True when at least one memory was durably written by this call. */
124
+ committed: boolean;
125
+ /** Drafts refused before writing, with the gate that refused them. */
126
+ rejections: DraftRejection[];
108
127
  emotion: Emotion;
109
128
  /** Analysis of the user turn. Empty signal for assistant turns or on failure. */
110
129
  affect: AffectSignal;
@@ -148,3 +167,30 @@ export interface AffectSignal {
148
167
  export declare const EMPTY_AFFECT: AffectSignal;
149
168
  /** Wire version of the four verbs. Additive changes bump MINOR (ADR 0005). */
150
169
  export declare const PROTOCOL_VERSION = "0.2";
170
+ /**
171
+ * Evidence, not memory: a user correction captured with the reply it targeted.
172
+ * Stored separately so it never reaches a prompt; distillation (RFC 0003 phase B)
173
+ * is what turns evidence into a strategy.
174
+ */
175
+ export interface Correction {
176
+ id: string;
177
+ soulId: string;
178
+ userId: string;
179
+ at: number;
180
+ /** The user's own words, complete. */
181
+ userText: string;
182
+ /** The reply the correction targeted, complete; undefined when unknown. */
183
+ assistantText?: string;
184
+ /** Which marker matched (for statistics). */
185
+ rule?: string;
186
+ deviceId?: string;
187
+ }
188
+ export interface CorrectionInput {
189
+ soulId: string;
190
+ userId: string;
191
+ userText: string;
192
+ assistantText?: string;
193
+ rule?: string;
194
+ deviceId?: string;
195
+ at?: number;
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
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",