soulbridge 0.0.7 → 0.0.9

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.
@@ -303,6 +303,27 @@ async function main() {
303
303
  out({ ok: true, file: resolve(file), turns: turns.length, skipped, reflected, dryRun });
304
304
  return;
305
305
  }
306
+ case 'list': {
307
+ const { soul } = open(flags);
308
+ const memories = soul.list(flags.limit ? Number(flags.limit) : 20);
309
+ if (memories.length === 0) {
310
+ process.stdout.write('no memories yet\n');
311
+ return;
312
+ }
313
+ for (const memory of memories) {
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`);
316
+ }
317
+ return;
318
+ }
319
+ case 'forget': {
320
+ const id = positional[0];
321
+ if (!id)
322
+ throw new Error('usage: soulbridge forget <memory-id>');
323
+ const { soul } = open(flags);
324
+ out({ ok: soul.forget(id), id });
325
+ return;
326
+ }
306
327
  case 'status': {
307
328
  const config = ensureConfig();
308
329
  const dbPath = resolveDbPath(flags.db);
@@ -371,6 +392,8 @@ async function main() {
371
392
  ' import --in <file> 导入 JSONL',
372
393
  ' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
373
394
  ' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
395
+ ' list [--limit N] 看它记住了什么',
396
+ ' forget <id> 忘掉一条记忆(墓碑)',
374
397
  ' status 一眼看它是否在工作',
375
398
  ' stats 看原始状态(JSON)',
376
399
  ' demo 端到端演示(无需模型)',
@@ -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, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, Visibility } from './types.ts';
20
+ import type { AffectSignal, ContextRequest, 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 {
@@ -42,6 +42,10 @@ export declare class Soul {
42
42
  constructor(options: SoulOptions);
43
43
  /** Verb 1: session start. */
44
44
  context(request?: Partial<ContextRequest>): ContextResult;
45
+ /** Every active memory, newest first. For `list` and for auditing injection. */
46
+ list(limit?: number): Memory[];
47
+ /** Tombstone one memory so it never reaches a prompt again. */
48
+ forget(id: string): boolean;
45
49
  /** Verb 3: on demand. */
46
50
  recall(query: string, limit?: number): ScoredMemory[];
47
51
  /** Pure analysis, no storage. Async because the analyzer may call a model. */
@@ -63,7 +67,10 @@ export declare class Soul {
63
67
  visibility?: Visibility;
64
68
  }): string;
65
69
  /** Verb 4: session end. */
66
- reflect(turns: Turn[], llm?: LlmFn): Promise<ReflectResult>;
70
+ reflect(turns: Turn[], options?: {
71
+ llm?: LlmFn;
72
+ updateEmotion?: boolean;
73
+ }): Promise<ReflectResult>;
67
74
  stats(): {
68
75
  memories: number;
69
76
  emotion: Emotion;
@@ -54,6 +54,14 @@ export class Soul {
54
54
  maxItems: request.maxItems,
55
55
  });
56
56
  }
57
+ /** Every active memory, newest first. For `list` and for auditing injection. */
58
+ list(limit = 50) {
59
+ return this.store.query({ soulId: this.soulId, userId: this.userId, limit });
60
+ }
61
+ /** Tombstone one memory so it never reaches a prompt again. */
62
+ forget(id) {
63
+ return this.store.forget(id);
64
+ }
57
65
  /** Verb 3: on demand. */
58
66
  recall(query, limit = 5) {
59
67
  const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
@@ -131,8 +139,14 @@ export class Soul {
131
139
  return id;
132
140
  }
133
141
  /** Verb 4: session end. */
134
- async reflect(turns, llm) {
135
- return reflect(this.store, { soulId: this.soulId, userId: this.userId, turns, llm });
142
+ async reflect(turns, options = {}) {
143
+ return reflect(this.store, {
144
+ soulId: this.soulId,
145
+ userId: this.userId,
146
+ turns,
147
+ llm: options.llm,
148
+ updateEmotion: options.updateEmotion,
149
+ });
136
150
  }
137
151
  stats() {
138
152
  return {
@@ -11,7 +11,19 @@ interface Rule {
11
11
  type: MemoryType;
12
12
  importance: number;
13
13
  match: (text: string) => boolean;
14
+ /** Optional rewrite: keep the instruction, drop the surrounding discussion. */
15
+ distill?: (text: string) => string | null;
14
16
  }
17
+ /** Long strategies are noise in a prompt; keep the instruction, not the essay. */
18
+ export declare const MAX_STRATEGY_CHARS = 80;
19
+ /**
20
+ * Cut a standing instruction down to the clause that carries it.
21
+ *
22
+ * A user explains for two lines and states the rule once; storing the whole
23
+ * paragraph repeats the discussion into every future prompt. Find the trigger
24
+ * whose neighbourhood also carries a request, and keep from there.
25
+ */
26
+ export declare function distillStrategy(text: string): string | null;
15
27
  /** Cheap, synchronous, no model. Deliberately conservative: false positives cost trust. */
16
28
  export declare const RULES: Rule[];
17
29
  export declare function ruleExtract(turns: Turn[]): Draft[];
@@ -22,6 +34,12 @@ export interface ReflectOptions {
22
34
  userId: string;
23
35
  turns: Turn[];
24
36
  now?: number;
37
+ /**
38
+ * Whether this call also advances emotion (default true). A caller that runs
39
+ * reflect per turn, alongside observe(), must pass false or the turn is
40
+ * counted twice.
41
+ */
42
+ updateEmotion?: boolean;
25
43
  /** Reserved for richer extraction; unused in v0. */
26
44
  llm?: LlmFn;
27
45
  }
@@ -14,6 +14,38 @@ import { tokenize } from './scoring.js';
14
14
  */
15
15
  const TIME_TRIGGER = /(以后|下次|每次|从现在起|在.{0,12}之前|from now on|always)/i;
16
16
  const DIRECTIVE = /(先|需要|要|必须|应该|请|别|不要|务必|记得|need to|should|must|please)/i;
17
+ /** Long strategies are noise in a prompt; keep the instruction, not the essay. */
18
+ export const MAX_STRATEGY_CHARS = 80;
19
+ /** How far after a trigger the request may sit and still belong to it. */
20
+ const DIRECTIVE_WINDOW = 40;
21
+ /**
22
+ * Cut a standing instruction down to the clause that carries it.
23
+ *
24
+ * A user explains for two lines and states the rule once; storing the whole
25
+ * paragraph repeats the discussion into every future prompt. Find the trigger
26
+ * whose neighbourhood also carries a request, and keep from there.
27
+ */
28
+ export function distillStrategy(text) {
29
+ const trigger = new RegExp(TIME_TRIGGER.source, 'gi');
30
+ let match = trigger.exec(text);
31
+ let start = -1;
32
+ // The rule usually closes the paragraph ("...比如你每次…都需要…"), so the
33
+ // last qualifying trigger is the instruction, not the setup.
34
+ while (match !== null) {
35
+ const rest = text.slice(match.index);
36
+ if (DIRECTIVE.test(rest.slice(0, DIRECTIVE_WINDOW)))
37
+ start = match.index;
38
+ match = trigger.exec(text);
39
+ }
40
+ if (start < 0)
41
+ return null;
42
+ let clause = text.slice(start).replace(/^[\s,,、::]+/, '');
43
+ clause = clause.replace(/^(比如|例如|就像|也就是说|你|我)\s*/g, '');
44
+ if (clause.length > MAX_STRATEGY_CHARS)
45
+ clause = clause.slice(0, MAX_STRATEGY_CHARS);
46
+ clause = clause.trim();
47
+ return clause.length > 0 ? clause : null;
48
+ }
17
49
  /** Cheap, synchronous, no model. Deliberately conservative: false positives cost trust. */
18
50
  export const RULES = [
19
51
  {
@@ -33,6 +65,7 @@ export const RULES = [
33
65
  type: 'strategy',
34
66
  importance: 7,
35
67
  match: (text) => TIME_TRIGGER.test(text) && DIRECTIVE.test(text),
68
+ distill: distillStrategy,
36
69
  },
37
70
  {
38
71
  id: 'decision',
@@ -50,10 +83,13 @@ export function ruleExtract(turns) {
50
83
  if (turn.role !== 'user')
51
84
  continue;
52
85
  for (const rule of RULES) {
53
- if (rule.match(turn.content)) {
54
- drafts.push({ type: rule.type, content: turn.content.trim(), importance: rule.importance });
55
- break;
56
- }
86
+ if (!rule.match(turn.content))
87
+ continue;
88
+ // A rule may rewrite the turn; a strategy we cannot phrase is not stored.
89
+ const content = rule.distill ? rule.distill(turn.content) : turn.content.trim();
90
+ if (content)
91
+ drafts.push({ type: rule.type, content, importance: rule.importance });
92
+ break;
57
93
  }
58
94
  }
59
95
  return drafts;
@@ -108,7 +144,10 @@ export async function reflect(store, options) {
108
144
  store.put(memory);
109
145
  written += 1;
110
146
  }
111
- const emotion = nextEmotion(store.emotion(options.soulId, options.userId), options.turns);
112
- store.saveEmotion(options.soulId, options.userId, emotion);
147
+ let emotion = store.emotion(options.soulId, options.userId);
148
+ if (options.updateEmotion !== false) {
149
+ emotion = nextEmotion(emotion, options.turns);
150
+ store.saveEmotion(options.soulId, options.userId, emotion);
151
+ }
113
152
  return { memories: written, superseded: 0, emotion };
114
153
  }
@@ -225,6 +225,12 @@ export function openSqlite(file) {
225
225
  stmt.run(now, id);
226
226
  });
227
227
  },
228
+ forget(id, now = Date.now()) {
229
+ const result = withRetry(() => db
230
+ .prepare('UPDATE memories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL')
231
+ .run(now, now, id));
232
+ return Number(result.changes ?? 0) > 0;
233
+ },
228
234
  saveAffect(soulId, userId, signal, at, deviceId) {
229
235
  withRetry(() => {
230
236
  db.prepare(`INSERT OR IGNORE INTO affect_samples
@@ -13,6 +13,11 @@ export interface SoulStore {
13
13
  query(spec: QuerySpec): Memory[];
14
14
  /** Recall bookkeeping: recallCount++, lastRecalledAt = now. */
15
15
  touch(ids: string[], now?: number): void;
16
+ /**
17
+ * Tombstone a memory so it never surfaces again (sync-safe: the row stays for
18
+ * merge, the deletion wins by updatedAt). Returns false when no such active row.
19
+ */
20
+ forget(id: string, now?: number): boolean;
16
21
  /** Persist one affect sample (payload only; no raw text). */
17
22
  saveAffect(soulId: string, userId: string, signal: AffectSignal, at: number, deviceId?: string): void;
18
23
  /** Samples newer than `sinceMs`, newest first. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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",