soulbridge 0.0.13 → 0.0.15

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;聊天里达成的不算决定。
@@ -312,7 +312,9 @@ async function main() {
312
312
  }
313
313
  for (const memory of memories) {
314
314
  const preview = memory.content.replace(/\s+/g, ' ').slice(0, 72);
315
- process.stdout.write(`${memory.id} ${memory.type.padEnd(9)} imp=${String(memory.importance).padEnd(2)} ${preview}\n`);
315
+ const status = memory.status === 'active' ? '' : ` [${memory.status}]`;
316
+ const superseded = memory.supersededBy ? ` -> ${memory.supersededBy}` : '';
317
+ process.stdout.write(`${memory.id} ${memory.type.padEnd(9)} imp=${String(memory.importance).padEnd(2)}${status}${superseded} ${preview}\n`);
316
318
  }
317
319
  return;
318
320
  }
@@ -324,6 +326,14 @@ async function main() {
324
326
  out({ ok: soul.forget(id), id });
325
327
  return;
326
328
  }
329
+ case 'supersede': {
330
+ const [oldId, newId] = positional;
331
+ if (!oldId || !newId)
332
+ throw new Error('usage: soulbridge supersede <old-id> <new-id>');
333
+ const { soul } = open(flags);
334
+ out({ ok: soul.supersede(oldId, newId), oldId, newId });
335
+ return;
336
+ }
327
337
  case 'corrections': {
328
338
  const { soul } = open(flags);
329
339
  const rows = soul.corrections(flags.limit ? Number(flags.limit) : 10);
@@ -344,9 +354,19 @@ async function main() {
344
354
  const config = ensureConfig();
345
355
  const dbPath = resolveDbPath(flags.db);
346
356
  const store = openSqlite(dbPath);
347
- const memories = store.query({ soulId: config.soulId, userId: config.userId, limit: 100_000 });
357
+ const memories = store.query({
358
+ soulId: config.soulId,
359
+ userId: config.userId,
360
+ status: ['active', 'superseded'],
361
+ includeExpired: true,
362
+ limit: 100_000,
363
+ });
348
364
  const samples = store.recentAffect(config.soulId, config.userId, 0);
349
365
  store.close();
366
+ const now = Date.now();
367
+ const activeCount = memories.filter((m) => m.status === 'active' && (m.validUntil === undefined || m.validUntil > now)).length;
368
+ const supersededCount = memories.filter((m) => m.status === 'superseded').length;
369
+ const expiredCount = memories.filter((m) => m.status === 'active' && m.validUntil !== undefined && m.validUntil <= now).length;
350
370
  const byType = new Map();
351
371
  for (const memory of memories)
352
372
  byType.set(memory.type, (byType.get(memory.type) ?? 0) + 1);
@@ -373,7 +393,7 @@ async function main() {
373
393
  const lines = [
374
394
  `soul ${config.soulId} / ${config.userId}`,
375
395
  `db ${dbPath}`,
376
- `memories ${memories.length} (${typeText})`,
396
+ `memories ${memories.length} (有效 ${activeCount} · 被取代 ${supersededCount} · 已过期 ${expiredCount}) (${typeText})`,
377
397
  `affect ${samples.length} samples${samples.length > 0 ? ` · last ${ago(samples[0].at)}` : ''}`,
378
398
  plugin
379
399
  ? `plugin v${plugin.version ?? '?'} · applied ${ago(plugin.appliedAt)} · inject ${plugin.injects ?? 0} · observe ${plugin.observes ?? 0} · fail ${plugin.observeFails ?? 0}`
@@ -411,6 +431,7 @@ async function main() {
411
431
  ' list [--limit N] 看它记住了什么',
412
432
  ' corrections [--limit N] 看被纠正过的证据',
413
433
  ' forget <id> 忘掉一条记忆(墓碑)',
434
+ ' supersede <old> <new> 用新记忆取代旧记忆(保留历史)',
414
435
  ' status 一眼看它是否在工作',
415
436
  ' stats 看原始状态(JSON)',
416
437
  ' demo 端到端演示(无需模型)',
@@ -52,10 +52,15 @@ export declare class Soul {
52
52
  constructor(options: SoulOptions);
53
53
  /** Verb 1: session start. */
54
54
  context(request?: Partial<ContextRequest>): ContextResult;
55
- /** Every active memory, newest first. For `list` and for auditing injection. */
55
+ /**
56
+ * Audit view: every non-deleted row, including superseded and expired ones.
57
+ * Prompt builders use `context`, which filters both out.
58
+ */
56
59
  list(limit?: number): Memory[];
57
60
  /** Tombstone one memory so it never reaches a prompt again. */
58
61
  forget(id: string): boolean;
62
+ /** Mark `oldId` replaced by `newId`; neither row is deleted (RFC 0004). */
63
+ supersede(oldId: string, newId: string): boolean;
59
64
  /**
60
65
  * Record a user correction as evidence (RFC 0003 phase A). Nothing here
61
66
  * reaches a prompt; distillation to a strategy is a later phase.
@@ -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;
@@ -59,14 +59,27 @@ export class Soul {
59
59
  maxItems: request.maxItems,
60
60
  });
61
61
  }
62
- /** Every active memory, newest first. For `list` and for auditing injection. */
62
+ /**
63
+ * Audit view: every non-deleted row, including superseded and expired ones.
64
+ * Prompt builders use `context`, which filters both out.
65
+ */
63
66
  list(limit = 50) {
64
- return this.store.query({ soulId: this.soulId, userId: this.userId, limit });
67
+ return this.store.query({
68
+ soulId: this.soulId,
69
+ userId: this.userId,
70
+ status: ['active', 'superseded'],
71
+ includeExpired: true,
72
+ limit,
73
+ });
65
74
  }
66
75
  /** Tombstone one memory so it never reaches a prompt again. */
67
76
  forget(id) {
68
77
  return this.store.forget(id);
69
78
  }
79
+ /** Mark `oldId` replaced by `newId`; neither row is deleted (RFC 0004). */
80
+ supersede(oldId, newId) {
81
+ return this.store.supersede(oldId, newId);
82
+ }
70
83
  /**
71
84
  * Record a user correction as evidence (RFC 0003 phase A). Nothing here
72
85
  * reaches a prompt; distillation to a strategy is a later phase.
@@ -129,10 +142,13 @@ export class Soul {
129
142
  }
130
143
  }
131
144
  }
145
+ // Deterministic write gates: shape -> semantics -> per-turn budget. Whatever
146
+ // they refuse is reported, so written: 0 always has a reason.
147
+ const gated = gateDrafts(drafts);
132
148
  // observe runs every turn; repeating a sentence must not create a second row.
133
149
  const seen = new Set(this.store.query({ soulId: this.soulId, userId: this.userId, limit: 10_000 }).map((m) => m.content.trim()));
134
150
  let written = 0;
135
- for (const draft of drafts) {
151
+ for (const draft of gated.accepted) {
136
152
  if (seen.has(draft.content.trim()))
137
153
  continue;
138
154
  seen.add(draft.content.trim());
@@ -164,7 +180,14 @@ export class Soul {
164
180
  }
165
181
  const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
166
182
  this.store.saveEmotion(this.soulId, this.userId, emotion, now);
167
- return { written, emotion, affect, extractedBy: written > 0 ? extractedBy : 'none' };
183
+ return {
184
+ written,
185
+ committed: written > 0,
186
+ rejections: gated.rejected,
187
+ emotion,
188
+ affect,
189
+ extractedBy: written > 0 ? extractedBy : 'none',
190
+ };
168
191
  }
169
192
  /**
170
193
  * 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;
@@ -30,6 +30,20 @@ export declare const RULES: Rule[];
30
30
  export declare function isCorrection(text: string): boolean;
31
31
  /** Which marker matched, for statistics; null when the turn is not a correction. */
32
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
+ };
33
47
  export declare function ruleExtract(turns: Turn[]): Draft[];
34
48
  /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
35
49
  export declare function nextEmotion(current: Emotion, turns: Turn[]): Emotion;
@@ -86,6 +86,41 @@ export function correctionRule(text) {
86
86
  }
87
87
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
88
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
+ }
89
124
  export function ruleExtract(turns) {
90
125
  const drafts = [];
91
126
  for (const turn of turns) {
@@ -149,8 +184,9 @@ export async function reflect(store, options) {
149
184
  }
150
185
  const existing = store.query({ soulId: options.soulId, userId: options.userId });
151
186
  const seen = new Set(existing.map((m) => m.content));
187
+ const { accepted, rejected } = gateDrafts(drafts);
152
188
  let written = 0;
153
- for (const draft of drafts) {
189
+ for (const draft of accepted) {
154
190
  if (seen.has(draft.content))
155
191
  continue;
156
192
  seen.add(draft.content);
@@ -177,7 +213,7 @@ export async function reflect(store, options) {
177
213
  emotion = nextEmotion(emotion, options.turns);
178
214
  store.saveEmotion(options.soulId, options.userId, emotion);
179
215
  }
180
- return { memories: written, superseded: 0, emotion };
216
+ return { memories: written, superseded: null, emotion, committed: written > 0, rejections: rejected };
181
217
  }
182
218
  /* ------------------------------------------------------------------ *
183
219
  * LLM distillation port (optional)
@@ -236,12 +236,15 @@ export function openSqlite(file) {
236
236
  query(spec) {
237
237
  const statuses = spec.status ?? ['active'];
238
238
  const placeholders = statuses.map(() => '?').join(', ');
239
+ const now = spec.now ?? Date.now();
240
+ // Expiry is a filter, not a deletion: audit views can still see the row.
241
+ const expiry = spec.includeExpired ? '' : 'AND (valid_until IS NULL OR valid_until > ?)';
239
242
  const rows = db
240
243
  .prepare(`SELECT * FROM memories
241
244
  WHERE soul_id = ? AND (user_id = ? OR visibility = 'shared')
242
- AND status IN (${placeholders}) AND deleted_at IS NULL
245
+ AND status IN (${placeholders}) AND deleted_at IS NULL ${expiry}
243
246
  ORDER BY created_at DESC LIMIT ?`)
244
- .all(spec.soulId, spec.userId, ...statuses, spec.limit ?? 500);
247
+ .all(spec.soulId, spec.userId, ...statuses, ...(spec.includeExpired ? [] : [now]), spec.limit ?? 500);
245
248
  const memories = rows.map(rowToMemory);
246
249
  return spec.types ? memories.filter((m) => spec.types.includes(m.type)) : memories;
247
250
  },
@@ -258,6 +261,19 @@ export function openSqlite(file) {
258
261
  .run(now, now, id));
259
262
  return Number(result.changes ?? 0) > 0;
260
263
  },
264
+ /** Mark `oldId` as replaced by `newId`. Neither row is deleted. */
265
+ supersede(oldId, newId, now = Date.now()) {
266
+ const replacement = db
267
+ .prepare('SELECT id FROM memories WHERE id = ? AND deleted_at IS NULL')
268
+ .get(newId);
269
+ if (!replacement)
270
+ return false;
271
+ const result = withRetry(() => db
272
+ .prepare(`UPDATE memories SET status = 'superseded', superseded_by = ?, updated_at = ?
273
+ WHERE id = ? AND status = 'active' AND deleted_at IS NULL`)
274
+ .run(newId, now, oldId));
275
+ return Number(result.changes ?? 0) > 0;
276
+ },
261
277
  saveAffect(soulId, userId, signal, at, deviceId) {
262
278
  withRetry(() => {
263
279
  db.prepare(`INSERT OR IGNORE INTO affect_samples
@@ -18,6 +18,12 @@ export interface SoulStore {
18
18
  * merge, the deletion wins by updatedAt). Returns false when no such active row.
19
19
  */
20
20
  forget(id: string, now?: number): boolean;
21
+ /**
22
+ * Mark `oldId` as replaced by `newId` (status superseded, supersededBy set).
23
+ * Both rows stay; returns false when the replacement is missing or the old row
24
+ * is not active.
25
+ */
26
+ supersede(oldId: string, newId: string, now?: number): boolean;
21
27
  /** Persist one affect sample (payload only; no raw text). */
22
28
  saveAffect(soulId: string, userId: string, signal: AffectSignal, at: number, deviceId?: string): void;
23
29
  /** Samples newer than `sinceMs`, newest first. */
@@ -23,8 +23,11 @@ export interface Memory {
23
23
  keywords: string[];
24
24
  createdAt: number;
25
25
  lastRecalledAt?: number;
26
+ /** Written on create; no code filters by it yet (roadmap N3). */
26
27
  validFrom?: number;
28
+ /** Reserved for expiry filtering; not enforced yet (roadmap N3). */
27
29
  validUntil?: number;
30
+ /** Reserved for conflict replacement; not written yet (roadmap N3). */
28
31
  supersededBy?: string;
29
32
  /** Last-writer-wins clock for cross-device merge (ADR 0021). */
30
33
  updatedAt?: number;
@@ -81,12 +84,6 @@ export interface ContextResult {
81
84
  directives: string[];
82
85
  };
83
86
  }
84
- export interface RecallRequest {
85
- soulId: string;
86
- userId: string;
87
- q: string;
88
- limit?: number;
89
- }
90
87
  export interface ScoredMemory {
91
88
  memory: Memory;
92
89
  score: number;
@@ -97,14 +94,41 @@ export interface QuerySpec {
97
94
  status?: MemoryStatus[];
98
95
  types?: MemoryType[];
99
96
  limit?: number;
97
+ /** Clock for expiry checks; defaults to now. */
98
+ now?: number;
99
+ /**
100
+ * Audit views (`list`) need to see expired rows; prompt builders must not.
101
+ * Defaults to false, so expiry applies unless a caller asks otherwise.
102
+ */
103
+ includeExpired?: boolean;
100
104
  }
101
105
  export interface ReflectResult {
102
106
  memories: number;
103
- superseded: number;
107
+ /**
108
+ * How many older memories were replaced by a newer conflicting one.
109
+ *
110
+ ull` means this build does not track supersession yet — it used to report
111
+ * a hardcoded 0, which claimed a capability that did not exist.
112
+ */
113
+ superseded: number | null;
104
114
  emotion: Emotion;
115
+ /** True when at least one memory was durably written by this call. */
116
+ committed: boolean;
117
+ /** Drafts refused before writing, with the gate that refused them. */
118
+ rejections: DraftRejection[];
119
+ }
120
+ export type GateName = 'shape' | 'semantic' | 'budget';
121
+ export interface DraftRejection {
122
+ gate: GateName;
123
+ reason: string;
124
+ content: string;
105
125
  }
106
126
  export interface ObserveResult {
107
127
  written: number;
128
+ /** True when at least one memory was durably written by this call. */
129
+ committed: boolean;
130
+ /** Drafts refused before writing, with the gate that refused them. */
131
+ rejections: DraftRejection[];
108
132
  emotion: Emotion;
109
133
  /** Analysis of the user turn. Empty signal for assistant turns or on failure. */
110
134
  affect: AffectSignal;
@@ -166,12 +190,3 @@ export interface Correction {
166
190
  rule?: string;
167
191
  deviceId?: string;
168
192
  }
169
- export interface CorrectionInput {
170
- soulId: string;
171
- userId: string;
172
- userText: string;
173
- assistantText?: string;
174
- rule?: string;
175
- deviceId?: string;
176
- at?: number;
177
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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",