soulbridge 0.0.105 → 0.0.107

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.
@@ -20,3 +20,17 @@ export declare const CONCLUSION_TYPE = "\u7ED3\u8BBA";
20
20
  * readers; retyping it in a second filter is how a layer silently drops out.
21
21
  */
22
22
  export declare const ANCHOR_TYPES: readonly string[];
23
+ /**
24
+ * How a fact that came from my own reply is marked in injection (ADR 0035).
25
+ *
26
+ * Two renderers write fact lines — the core's `<memories>` block and the plugin's `<about>`
27
+ * block — and both must use the same marker, or the same fact reads differently depending on
28
+ * which path surfaced it. Declared here, imported by both.
29
+ */
30
+ export declare const INFERRED_MARKER = "\u6211: ";
31
+ /**
32
+ * One explanation per block, not one per line.
33
+ *
34
+ * 341 of 646 facts are inferred, so a sentence on every line would double the injection.
35
+ */
36
+ export declare const SOURCE_NOTES = "<source-notes>\u4EE5\u300C\u6211:\u300D\u5F00\u5934\u7684\u662F\u6211\u4E0A\u4E00\u8F6E\u8BF4\u7684\u8BDD\uFF0C\u672A\u7ECF\u7528\u6237\u786E\u8BA4\uFF1B\u6CA1\u6807\u7684\u90FD\u662F\u7528\u6237\u8BF4\u7684\u3002</source-notes>";
@@ -19,3 +19,17 @@ export const CONCLUSION_TYPE = '结论';
19
19
  * readers; retyping it in a second filter is how a layer silently drops out.
20
20
  */
21
21
  export const ANCHOR_TYPES = ['结论', '教训', '原则', '性格'];
22
+ /**
23
+ * How a fact that came from my own reply is marked in injection (ADR 0035).
24
+ *
25
+ * Two renderers write fact lines — the core's `<memories>` block and the plugin's `<about>`
26
+ * block — and both must use the same marker, or the same fact reads differently depending on
27
+ * which path surfaced it. Declared here, imported by both.
28
+ */
29
+ export const INFERRED_MARKER = '我: ';
30
+ /**
31
+ * One explanation per block, not one per line.
32
+ *
33
+ * 341 of 646 facts are inferred, so a sentence on every line would double the injection.
34
+ */
35
+ export const SOURCE_NOTES = '<source-notes>以「我:」开头的是我上一轮说的话,未经用户确认;没标的都是用户说的。</source-notes>';
@@ -1,5 +1,5 @@
1
1
  import { FACT_STATUS } from './config/status.js';
2
- import { ANCHOR_TYPES } from './config/labels.js';
2
+ import { ANCHOR_TYPES, INFERRED_MARKER, SOURCE_NOTES } from './config/labels.js';
3
3
  import { isActive, roleOf } from './roles.js';
4
4
  import { escapeAttr, escapeBlockText } from './inject-text.js';
5
5
  import { selectDirectives } from './affect/directives.js';
@@ -109,7 +109,7 @@ export function buildContext(store, request, now = Date.now()) {
109
109
  }
110
110
  // Escaping can lengthen a line (one `&lt;` is four characters), so cost is charged
111
111
  // on the escaped text — the budget has to measure what actually gets injected.
112
- const marker = anchor.inferred ? '我: ' : '';
112
+ const marker = anchor.inferred ? INFERRED_MARKER : '';
113
113
  const line = `- ${marker}[${escapeBlockText(anchor.label)}] ${escapeBlockText(anchor.text)}`;
114
114
  if (anchor.inferred)
115
115
  sawInferred = true;
@@ -124,9 +124,8 @@ export function buildContext(store, request, now = Date.now()) {
124
124
  }
125
125
  if (request.includeAnchors !== false && lines.length > 0)
126
126
  sections.push(`<memories>\n${lines.join('\n')}\n</memories>`);
127
- if (sawInferred) {
128
- sections.push('<source-notes>以「我:」开头的是我上一轮说的话,未经用户确认;没标的都是用户说的。</source-notes>');
129
- }
127
+ if (sawInferred)
128
+ sections.push(SOURCE_NOTES);
130
129
  const text = sections.join('\n\n');
131
130
  if (request.track !== false && used.length > 0)
132
131
  store.touch(used, now);
@@ -8,6 +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 { INFERRED_MARKER, SOURCE_NOTES } from './config/labels.ts';
11
12
  export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule, detectCorrection, isQuestion, } from './reflect.ts';
12
13
  export type { CorrectionLayer, CorrectionSignal, CorrectionVerdict } from './reflect.ts';
13
14
  export { rank, scoreMemory, tokenize, relevance, bm25, buildCorpusStats } from './scoring.ts';
@@ -326,6 +327,17 @@ export declare class Soul {
326
327
  id: string;
327
328
  reason: 'archived' | 'quiet-turns' | 'quiet-time';
328
329
  }>;
330
+ /**
331
+ * Housekeeping that should run once per turn: close open questions nobody answered.
332
+ *
333
+ * A gap is only worth asking about while it is fresh; 14 days of silence means the moment
334
+ * passed. Closing is not deleting — the row keeps its topic, opened_at and the reason.
335
+ */
336
+ sweep(now?: number, options?: {
337
+ gapMaxAgeDays?: number;
338
+ }): {
339
+ gapsClosed: number;
340
+ };
329
341
  /** The thread the previous turn belonged to, for the attribution candidate list. */
330
342
  threadBefore(sessionId: string, turnNo: number): string | null;
331
343
  /**
@@ -7,6 +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 { INFERRED_MARKER, SOURCE_NOTES } from './config/labels.js';
10
11
  export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule, detectCorrection, isQuestion, } from './reflect.js';
11
12
  export { rank, scoreMemory, tokenize, relevance, bm25, buildCorpusStats } from './scoring.js';
12
13
  export { parseThreadLine } from './extract.js';
@@ -534,22 +535,10 @@ export class Soul {
534
535
  slots,
535
536
  });
536
537
  facts.push(written);
537
- // Known to have happened, unknown when: keep the memory and turn the missing
538
- // date into something to ask about later, instead of inventing one.
539
- if (holder === 'world' && written.atPrecision === 'unknown') {
540
- const subject = slots.find((slot) => slot.value.kind === 'entity');
541
- gaps.push(this.store.putGap({
542
- soulId: this.soulId,
543
- userId: this.userId,
544
- aboutEntityId: subject?.value.kind === 'entity' ? subject.value.entityId : undefined,
545
- aboutFactId: written.id,
546
- // Whole statement: the gap asks "when did this happen", so the sentence it
547
- // refers to cannot be a prefix of itself.
548
- topic: `日期不明:${statement}`,
549
- tension: 0.3,
550
- openedAt: at,
551
- }));
552
- }
538
+ // A missing date is a property of the fact (`atPrecision: unknown`), not an open
539
+ // question. Turning every undated fact into a gap produced 223 open gaps in two days,
540
+ // of which 78% were '日期不明:…' — they can never be closed, and they bury the gaps
541
+ // that are worth asking about. The fact stays; the question is not created.
553
542
  return written;
554
543
  };
555
544
  // Operations carry the merge decision (Mem0's ADD/UPDATE/DELETE); plain facts are
@@ -817,6 +806,17 @@ export class Soul {
817
806
  closeIdleThreads(sessionId, options = {}) {
818
807
  return this.store.closeIdleThreads(this.soulId, this.userId, sessionId, options.idleTurns ?? 6, options.idleMs ?? 24 * 60 * 60_000, options.archivedSessionIds ?? [], options.at ?? Date.now());
819
808
  }
809
+ /**
810
+ * Housekeeping that should run once per turn: close open questions nobody answered.
811
+ *
812
+ * A gap is only worth asking about while it is fresh; 14 days of silence means the moment
813
+ * passed. Closing is not deleting — the row keeps its topic, opened_at and the reason.
814
+ */
815
+ sweep(now = Date.now(), options = {}) {
816
+ const maxAgeDays = options.gapMaxAgeDays ?? 14;
817
+ const gapsClosed = this.store.closeStaleGaps(this.soulId, this.userId, now - maxAgeDays * 86_400_000, 'expired');
818
+ return { gapsClosed };
819
+ }
820
820
  /** The thread the previous turn belonged to, for the attribution candidate list. */
821
821
  threadBefore(sessionId, turnNo) {
822
822
  return this.store.threadBefore(sessionId, turnNo);
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import type { SoulStore } from './store.ts';
3
- export declare const SCHEMA_VERSION = 11;
3
+ export declare const SCHEMA_VERSION = 12;
4
4
  export declare const AFFECT_RETENTION_MS: number;
5
5
  export declare class DbNewerThanBinaryError extends Error {
6
6
  readonly code = "DB_NEWER_THAN_BINARY";
@@ -13,6 +13,7 @@ export declare class DbNewerThanBinaryError extends Error {
13
13
  * old table stays as a read-only legacy copy.
14
14
  */
15
15
  /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
16
+ export declare function upgradeToV12(db: DatabaseSync): void;
16
17
  export declare function upgradeToV11(db: DatabaseSync): void;
17
18
  export declare function upgradeToV10(db: DatabaseSync): void;
18
19
  export declare function upgradeToV9(db: DatabaseSync): void;
@@ -12,7 +12,7 @@ import { DEFAULT_EMOTION } from './types.js';
12
12
  import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
13
13
  import { FACT_ROLES } from './config/domains.js';
14
14
  import { bm25, buildCorpusStats, tokenize } from './scoring.js';
15
- export const SCHEMA_VERSION = 11;
15
+ export const SCHEMA_VERSION = 12;
16
16
  /** How many recent pairs are considered before scoring. Bounded so recall stays O(candidates). */
17
17
  const RECALL_CANDIDATES = 500;
18
18
  export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
@@ -212,7 +212,8 @@ CREATE TABLE IF NOT EXISTS gaps (
212
212
  status TEXT NOT NULL DEFAULT 'open',
213
213
  opened_at INTEGER NOT NULL,
214
214
  closed_at INTEGER,
215
- closed_by_fact_id TEXT
215
+ closed_by_fact_id TEXT,
216
+ close_reason TEXT
216
217
  );
217
218
  CREATE INDEX IF NOT EXISTS idx_gaps_open ON gaps(soul_id, user_id, status, tension DESC);
218
219
 
@@ -299,6 +300,14 @@ function upgradeToV3(db) {
299
300
  * old table stays as a read-only legacy copy.
300
301
  */
301
302
  /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
303
+ export function upgradeToV12(db) {
304
+ // A missing date is a property of the fact, not an open question. Existing gaps created by
305
+ // that old rule are closed with the reason, so the remaining open gaps are the ones that
306
+ // were worth asking about.
307
+ ensureColumn(db, 'gaps', 'close_reason', 'TEXT');
308
+ db.prepare(`UPDATE gaps SET status = 'closed', closed_at = ?, close_reason = 'time-unknown-not-a-gap'
309
+ WHERE status = 'open' AND topic LIKE '日期不明:%'`).run(Date.now());
310
+ }
302
311
  export function upgradeToV11(db) {
303
312
  // 原则候选 stored a decision as a slot; it is role+status now. The row becomes a lesson
304
313
  // candidate — held and shown to the classifier, injected nowhere until it is placed.
@@ -463,8 +472,8 @@ export function openSqlite(file) {
463
472
  upgradeToV7(db);
464
473
  if (version < 8)
465
474
  upgradeToV8(db);
466
- if (version < 11)
467
- upgradeToV11(db);
475
+ if (version < 12)
476
+ upgradeToV12(db);
468
477
  // v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
469
478
  setVersion(db, SCHEMA_VERSION);
470
479
  db.exec('COMMIT');
@@ -918,6 +927,17 @@ export function openSqlite(file) {
918
927
  WHERE id = ?`)
919
928
  .run(at, closedByFactId ?? null, id));
920
929
  },
930
+ closeStaleGaps(soulId, userId, beforeAt, reason) {
931
+ let changed = 0;
932
+ withRetry(() => {
933
+ const result = db
934
+ .prepare(`UPDATE gaps SET status = 'closed', closed_at = ?, close_reason = ?
935
+ WHERE soul_id = ? AND user_id = ? AND status = 'open' AND opened_at < ?`)
936
+ .run(Date.now(), reason, soulId, userId, beforeAt);
937
+ changed = Number(result.changes);
938
+ });
939
+ return changed;
940
+ },
921
941
  turn(id) {
922
942
  const row = db.prepare('SELECT * FROM turns WHERE id = ?').get(id);
923
943
  return row ? rowToTurnPair(row) : null;
@@ -103,6 +103,11 @@ 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
+ /**
107
+ * Close every open gap older than `beforeAt`, recording why. Returns how many were closed.
108
+ * An open question that nobody answered in weeks is not a question any more.
109
+ */
110
+ closeStaleGaps(soulId: string, userId: string, beforeAt: number, reason: string): number;
106
111
  /** Corrections the user made, newest first — the objective signal for behaviour changes. */
107
112
  corrections(soulId: string, userId: string, limit?: number): Correction[];
108
113
  /** Everything a portable bundle needs, scoped to one soul/user. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.105",
3
+ "version": "0.0.107",
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",