soulbridge 0.0.21 → 0.0.26

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.
@@ -8,8 +8,9 @@ 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, isCorrection, correctionRule } from './reflect.ts';
12
- export { rank, scoreMemory, tokenize, relevance } from './scoring.ts';
11
+ export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule, detectCorrection, isQuestion, } from './reflect.ts';
12
+ export type { CorrectionLayer, CorrectionSignal, CorrectionVerdict } from './reflect.ts';
13
+ export { rank, scoreMemory, tokenize, relevance, bm25, buildCorpusStats } from './scoring.ts';
13
14
  export * from './affect/index.ts';
14
15
  export { measureStyle } from './style/measure.ts';
15
16
  export type { StyleMetrics } from './style/measure.ts';
@@ -17,7 +18,7 @@ export * from './storage/config.ts';
17
18
  export * from './storage/merge.ts';
18
19
  export * from './storage/steering.ts';
19
20
  export * from './storage/transcript.ts';
20
- import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, Visibility } from './types.ts';
21
+ import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, TurnPair, Visibility } from './types.ts';
21
22
  import type { SoulStore } from './store.ts';
22
23
  import type { AffectAnalyzer } from './affect/port.ts';
23
24
  export { gateDrafts } from './reflect.ts';
@@ -85,6 +86,22 @@ export declare class Soul {
85
86
  rule?: string;
86
87
  }): string;
87
88
  corrections(limit?: number): Correction[];
89
+ /**
90
+ * Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
91
+ * ignored by the store, so this returns nothing — there is no id worth handing
92
+ * back that would also be correct on a repeat.
93
+ */
94
+ recordTurnPair(input: {
95
+ sessionId: string;
96
+ turn: number;
97
+ userText: string;
98
+ assistantText: string;
99
+ at?: number;
100
+ metadata?: Record<string, unknown>;
101
+ }): void;
102
+ /** Recall exchanges, scored against `query` when it is non-empty. */
103
+ recallTurnPairs(query: string, limit?: number): TurnPair[];
104
+ countTurnPairs(): number;
88
105
  /** Verb 3: on demand. */
89
106
  recall(query: string, limit?: number): ScoredMemory[];
90
107
  /** Pure analysis, no storage. Async because the analyzer may call a model. */
@@ -7,8 +7,8 @@
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, isCorrection, correctionRule } from './reflect.js';
11
- export { rank, scoreMemory, tokenize, relevance } from './scoring.js';
10
+ export { reflect, ruleExtract, nextEmotion, isCorrection, correctionRule, detectCorrection, isQuestion, } from './reflect.js';
11
+ export { rank, scoreMemory, tokenize, relevance, bm25, buildCorpusStats } from './scoring.js';
12
12
  export * from './affect/index.js';
13
13
  export { measureStyle } from './style/measure.js';
14
14
  export * from './storage/config.js';
@@ -107,6 +107,38 @@ export class Soul {
107
107
  corrections(limit = 20) {
108
108
  return this.store.recentCorrections(this.soulId, this.userId, limit);
109
109
  }
110
+ /**
111
+ * Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
112
+ * ignored by the store, so this returns nothing — there is no id worth handing
113
+ * back that would also be correct on a repeat.
114
+ */
115
+ recordTurnPair(input) {
116
+ const at = input.at ?? Date.now();
117
+ this.store.putTurnPair({
118
+ id: `tp_${at.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
119
+ soulId: this.soulId,
120
+ userId: this.userId,
121
+ sessionId: input.sessionId,
122
+ turn: input.turn,
123
+ at,
124
+ userText: input.userText,
125
+ assistantText: input.assistantText,
126
+ metadata: input.metadata ?? {},
127
+ createdAt: at,
128
+ });
129
+ }
130
+ /** Recall exchanges, scored against `query` when it is non-empty. */
131
+ recallTurnPairs(query, limit = 5) {
132
+ return this.store.recallTurnPairs({
133
+ soulId: this.soulId,
134
+ userId: this.userId,
135
+ query,
136
+ limit,
137
+ });
138
+ }
139
+ countTurnPairs() {
140
+ return this.store.countTurnPairs(this.soulId, this.userId);
141
+ }
110
142
  /** Verb 3: on demand. */
111
143
  recall(query, limit = 5) {
112
144
  const memories = this.store.query({ soulId: this.soulId, userId: this.userId });
@@ -30,6 +30,26 @@ 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
+ export type CorrectionLayer = 'rule' | 'structural' | 'none';
34
+ export type CorrectionSignal = 'negation' | 'replacement' | 'meta';
35
+ export interface CorrectionVerdict {
36
+ isCorrection: boolean;
37
+ layer: CorrectionLayer;
38
+ /** L1 命中的词,或 L2 的信号名;便于统计"为什么记/为什么没记" */
39
+ marker: string | null;
40
+ }
41
+ /**
42
+ * L1 + L2 correction detection.
43
+ *
44
+ * L1: explicit markers, high precision. L2: structure — a negator/replacement/meta
45
+ * marker aimed at what the previous reply claimed. L2 only runs when a previous
46
+ * reply exists, and negation additionally requires ≥2 shared bigrams, so a bare
47
+ * "不行" about the weather is not mistaken for a correction.
48
+ *
49
+ * What this deliberately does NOT do: judge "是否真的不同意" — that needs a model.
50
+ * 有否定 + 指向上一轮 只能说明"这是候选",不能说明"用户是对的"。
51
+ */
52
+ export declare function detectCorrection(text: string, previousReply?: string): CorrectionVerdict;
33
53
  /** Types an extractor may produce; episodic/relationship are host-local. */
34
54
  export declare const EXTRACTABLE_TYPES: MemoryType[];
35
55
  export declare const MAX_CONTENT_CHARS = 500;
@@ -44,6 +64,13 @@ export declare function gateDrafts(drafts: Draft[]): {
44
64
  accepted: Draft[];
45
65
  rejected: DraftRejection[];
46
66
  };
67
+ /**
68
+ * A question is not a statement to remember.
69
+ *
70
+ * Measured case: "我们这个记忆会记住日常生活吗?" was stored as a fact because a
71
+ * trigger word matched before anything looked at the sentence type.
72
+ */
73
+ export declare function isQuestion(text: string): boolean;
47
74
  export declare function ruleExtract(turns: Turn[]): Draft[];
48
75
  /** Corrections raise tension and lower passion; an ordinary session lets tension decay. */
49
76
  export declare function nextEmotion(current: Emotion, turns: Turn[]): Emotion;
@@ -84,6 +84,68 @@ export function correctionRule(text) {
84
84
  const match = CORRECTION.exec(text);
85
85
  return match ? match[0] : null;
86
86
  }
87
+ /**
88
+ * A-not-A questions and fixed positive words contain a negator but negate nothing.
89
+ * Without these exceptions the structural layer fires on half of ordinary questions
90
+ * (是不是 / 可不可以 / 不错 / 不断 / 算不算).
91
+ */
92
+ const NEGATION_EXEMPT = /是不是|可不可以|有没有|要不要|对不对|好不好|算不算|是否可以|不错|不断|不仅|不但|不过|不只是/g;
93
+ const NEGATOR = /[不没别]/;
94
+ /** Replacement: the user supplies an alternative instead of denying. */
95
+ const REPLACEMENT = /我想的是|我的意思是|应该是|应该用|改用|换成|不是[^。;]{0,20}而是/;
96
+ /** Meta: the user refers to what the assistant just did. */
97
+ const META = /你刚才|你上一步|你上次|你前面|刚才那/;
98
+ function sharedBigrams(a, b) {
99
+ const grams = (s) => {
100
+ const lower = s.toLowerCase();
101
+ const out = new Set();
102
+ for (const word of lower.match(/[a-z0-9_]{2,}/g) ?? [])
103
+ out.add(word);
104
+ for (const run of lower.match(/[\u4e00-\u9fa5]+/g) ?? []) {
105
+ for (let i = 0; i < run.length - 1; i += 1)
106
+ out.add(run.slice(i, i + 2));
107
+ }
108
+ return out;
109
+ };
110
+ const left = grams(a);
111
+ const right = grams(b);
112
+ let hit = 0;
113
+ for (const g of left)
114
+ if (right.has(g))
115
+ hit += 1;
116
+ return hit;
117
+ }
118
+ /**
119
+ * L1 + L2 correction detection.
120
+ *
121
+ * L1: explicit markers, high precision. L2: structure — a negator/replacement/meta
122
+ * marker aimed at what the previous reply claimed. L2 only runs when a previous
123
+ * reply exists, and negation additionally requires ≥2 shared bigrams, so a bare
124
+ * "不行" about the weather is not mistaken for a correction.
125
+ *
126
+ * What this deliberately does NOT do: judge "是否真的不同意" — that needs a model.
127
+ * 有否定 + 指向上一轮 只能说明"这是候选",不能说明"用户是对的"。
128
+ */
129
+ export function detectCorrection(text, previousReply) {
130
+ const l1 = CORRECTION.exec(text);
131
+ if (l1)
132
+ return { isCorrection: true, layer: 'rule', marker: l1[0] };
133
+ if (!previousReply || previousReply.length === 0) {
134
+ return { isCorrection: false, layer: 'none', marker: null };
135
+ }
136
+ const stripped = text.replace(NEGATION_EXEMPT, ' ');
137
+ if (NEGATOR.test(stripped) && sharedBigrams(text, previousReply) >= 2) {
138
+ return { isCorrection: true, layer: 'structural', marker: 'negation' };
139
+ }
140
+ const replacement = REPLACEMENT.exec(text);
141
+ if (replacement)
142
+ return { isCorrection: true, layer: 'structural', marker: 'replacement' };
143
+ const meta = META.exec(text);
144
+ if (meta && (NEGATOR.test(stripped) || /[??]/.test(text))) {
145
+ return { isCorrection: true, layer: 'structural', marker: 'meta' };
146
+ }
147
+ return { isCorrection: false, layer: 'none', marker: null };
148
+ }
87
149
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
88
150
  const round2 = (n) => Math.round(n * 100) / 100;
89
151
  /** Types an extractor may produce; episodic/relationship are host-local. */
@@ -121,11 +183,25 @@ export function gateDrafts(drafts) {
121
183
  }
122
184
  return { accepted, rejected };
123
185
  }
186
+ /**
187
+ * A question is not a statement to remember.
188
+ *
189
+ * Measured case: "我们这个记忆会记住日常生活吗?" was stored as a fact because a
190
+ * trigger word matched before anything looked at the sentence type.
191
+ */
192
+ export function isQuestion(text) {
193
+ const trimmed = text.trim();
194
+ if (trimmed.endsWith('?') || trimmed.endsWith('?'))
195
+ return true;
196
+ return /(吗|呢)[。.!!~~]?$/.test(trimmed);
197
+ }
124
198
  export function ruleExtract(turns) {
125
199
  const drafts = [];
126
200
  for (const turn of turns) {
127
201
  if (turn.role !== 'user')
128
202
  continue;
203
+ if (isQuestion(turn.content))
204
+ continue;
129
205
  for (const rule of RULES) {
130
206
  if (!rule.match(turn.content))
131
207
  continue;
@@ -5,6 +5,15 @@
5
5
  *
6
6
  * The weights are a claim, not a truth: changing them changes behaviour, so they
7
7
  * live here (one place) and are covered by tests.
8
+ *
9
+ * relevance 有两种算法:
10
+ * - `relevance()`:命中比例。只看"命中了几个 query 词",不看稀有度、不看长度。
11
+ * - `bm25()`:IDF + 长度归一化 + 词频饱和。
12
+ *
13
+ * rank() 用 bm25。原因(真机 60 条记忆 / 298 条真实 query 实测):
14
+ * 旧算法下,一条 18202 字的聊天记录在 85% 的 query 里进 top3,top1 平均 12150 字;
15
+ * 换 bm25 后降到 17%,top1 平均 1294 字。文档长度在这个库里差三个数量级
16
+ * (4 字 ~ 18202 字),长度归一化是决定性的——这一点在长度均匀的语料上测不出来。
8
17
  */
9
18
  import type { Memory } from './types.ts';
10
19
  /** A memory's relevance halves every 30 days without being recalled. */
@@ -16,14 +25,30 @@ export declare const WEIGHTS: {
16
25
  readonly vividness: 0.15;
17
26
  readonly recall: 0.05;
18
27
  };
28
+ /** BM25 constants. k1 = term-frequency saturation, b = length normalization. */
29
+ export declare const BM25_K1 = 1.5;
30
+ export declare const BM25_B = 0.75;
19
31
  /**
20
32
  * CJK text has no spaces, so single characters are too coarse and the whole
21
33
  * sentence is too fine: use character bigrams. Latin runs become words.
22
34
  */
23
35
  export declare function tokenize(input: string): string[];
24
36
  export declare function relevance(queryTokens: string[], memory: Memory): number;
37
+ /** Corpus statistics BM25 needs. Must be built over the candidate set, not one memory. */
38
+ export interface CorpusStats {
39
+ idf: Map<string, number>;
40
+ avgdl: number;
41
+ }
42
+ /** Build idf / average length over a batch of tokenized documents. Call once per query. */
43
+ export declare function buildCorpusStats(docs: string[][]): CorpusStats;
44
+ /** BM25 over pre-tokenized document tokens. Length normalization is why this replaced plain recall. */
45
+ export declare function bm25(tokens: string[], docTokens: string[], stats: CorpusStats): number;
25
46
  export declare function recency(reference: number, now: number): number;
26
- export declare function scoreMemory(memory: Memory, queryTokens: string[], now: number): number;
47
+ /**
48
+ * @param relOverride 归一化到 0–1 的相关性。rank() 传 BM25 的归一化值;
49
+ * 省略时退回旧的命中比例,保持单独调用的行为不变。
50
+ */
51
+ export declare function scoreMemory(memory: Memory, queryTokens: string[], now: number, relOverride?: number): number;
27
52
  export declare function rank(memories: Memory[], query: string, now: number): Array<{
28
53
  memory: Memory;
29
54
  score: number;
@@ -7,6 +7,9 @@ export const WEIGHTS = {
7
7
  vividness: 0.15,
8
8
  recall: 0.05,
9
9
  };
10
+ /** BM25 constants. k1 = term-frequency saturation, b = length normalization. */
11
+ export const BM25_K1 = 1.5;
12
+ export const BM25_B = 0.75;
10
13
  /**
11
14
  * CJK text has no spaces, so single characters are too coarse and the whole
12
15
  * sentence is too fine: use character bigrams. Latin runs become words.
@@ -26,6 +29,10 @@ export function tokenize(input) {
26
29
  }
27
30
  return [...out];
28
31
  }
32
+ /** Token list of one memory, duplicates kept (BM25 needs term frequency). */
33
+ function memoryTokens(memory) {
34
+ return [...tokenize(memory.content), ...memory.keywords.map((k) => k.toLowerCase())];
35
+ }
29
36
  export function relevance(queryTokens, memory) {
30
37
  if (queryTokens.length === 0)
31
38
  return 0;
@@ -39,13 +46,55 @@ export function relevance(queryTokens, memory) {
39
46
  hit += 1;
40
47
  return hit / queryTokens.length;
41
48
  }
49
+ /** Build idf / average length over a batch of tokenized documents. Call once per query. */
50
+ export function buildCorpusStats(docs) {
51
+ const df = new Map();
52
+ let totalLen = 0;
53
+ for (const tokens of docs) {
54
+ totalLen += tokens.length;
55
+ for (const token of new Set(tokens))
56
+ df.set(token, (df.get(token) ?? 0) + 1);
57
+ }
58
+ const n = Math.max(1, docs.length);
59
+ const idf = new Map();
60
+ for (const [token, count] of df) {
61
+ // +1 keeps idf positive so a shared rare token never becomes a penalty.
62
+ idf.set(token, Math.log((n + 1) / (count + 1)) + 1);
63
+ }
64
+ return { idf, avgdl: totalLen / n };
65
+ }
66
+ /** BM25 over pre-tokenized document tokens. Length normalization is why this replaced plain recall. */
67
+ export function bm25(tokens, docTokens, stats) {
68
+ if (tokens.length === 0)
69
+ return 0;
70
+ if (docTokens.length === 0)
71
+ return 0;
72
+ const tf = new Map();
73
+ for (const token of docTokens)
74
+ tf.set(token, (tf.get(token) ?? 0) + 1);
75
+ const dl = docTokens.length;
76
+ const norm = 1 - BM25_B + (BM25_B * dl) / Math.max(1, stats.avgdl);
77
+ let score = 0;
78
+ for (const token of new Set(tokens)) {
79
+ const f = tf.get(token) ?? 0;
80
+ if (f === 0)
81
+ continue;
82
+ const idf = stats.idf.get(token) ?? 1;
83
+ score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * norm);
84
+ }
85
+ return score;
86
+ }
42
87
  export function recency(reference, now) {
43
88
  const days = Math.max(0, (now - reference) / 86_400_000);
44
89
  return Math.pow(0.5, days / RECENCY_HALF_LIFE_DAYS);
45
90
  }
46
91
  const clamp01 = (n) => Math.min(1, Math.max(0, n));
47
- export function scoreMemory(memory, queryTokens, now) {
48
- const rel = relevance(queryTokens, memory);
92
+ /**
93
+ * @param relOverride 归一化到 0–1 的相关性。rank() 传 BM25 的归一化值;
94
+ * 省略时退回旧的命中比例,保持单独调用的行为不变。
95
+ */
96
+ export function scoreMemory(memory, queryTokens, now, relOverride) {
97
+ const rel = relOverride ?? relevance(queryTokens, memory);
49
98
  const imp = clamp01(memory.importance / 10);
50
99
  const rec = recency(memory.lastRecalledAt ?? memory.createdAt, now);
51
100
  const viv = clamp01(memory.vividness);
@@ -60,8 +109,19 @@ export function rank(memories, query, now) {
60
109
  const tokens = tokenize(query);
61
110
  // A query is a selection: with one, a memory that shares no token is noise,
62
111
  // no matter how important it is. Without one (session start), rank by weight alone.
63
- const pool = tokens.length === 0 ? memories : memories.filter((m) => relevance(tokens, m) > 0);
64
- return pool
65
- .map((memory) => ({ memory, score: scoreMemory(memory, tokens, now) }))
112
+ if (tokens.length === 0) {
113
+ return memories
114
+ .map((memory) => ({ memory, score: scoreMemory(memory, tokens, now) }))
115
+ .sort((a, b) => b.score - a.score);
116
+ }
117
+ const stats = buildCorpusStats(memories.map(memoryTokens));
118
+ const scored = memories.map((memory) => ({ memory, raw: bm25(tokens, memoryTokens(memory), stats) }));
119
+ const best = scored.reduce((max, s) => Math.max(max, s.raw), 0);
120
+ if (best <= 0)
121
+ return [];
122
+ // Normalize to 0–1 so the weighted sum below keeps its original scale.
123
+ return scored
124
+ .filter((s) => s.raw > 0)
125
+ .map(({ memory, raw }) => ({ memory, score: scoreMemory(memory, tokens, now, raw / best) }))
66
126
  .sort((a, b) => b.score - a.score);
67
127
  }
@@ -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 = 2;
3
+ export declare const SCHEMA_VERSION = 4;
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";
@@ -9,7 +9,10 @@
9
9
  import { copyFileSync, existsSync } from 'node:fs';
10
10
  import { DatabaseSync } from 'node:sqlite';
11
11
  import { DEFAULT_EMOTION } from './types.js';
12
- export const SCHEMA_VERSION = 2;
12
+ import { bm25, buildCorpusStats, tokenize } from './scoring.js';
13
+ export const SCHEMA_VERSION = 4;
14
+ /** How many recent pairs are considered before scoring. Bounded so recall stays O(candidates). */
15
+ const RECALL_CANDIDATES = 500;
13
16
  export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
14
17
  const BUSY_TIMEOUT_MS = 5000;
15
18
  const WRITE_ATTEMPTS = 3;
@@ -87,6 +90,22 @@ CREATE TABLE IF NOT EXISTS corrections (
87
90
  device_id TEXT
88
91
  );
89
92
  CREATE INDEX IF NOT EXISTS idx_corrections ON corrections(soul_id, user_id, at DESC);
93
+ CREATE TABLE IF NOT EXISTS turn_pairs (
94
+ id TEXT PRIMARY KEY,
95
+ soul_id TEXT NOT NULL,
96
+ user_id TEXT NOT NULL,
97
+ session_id TEXT NOT NULL,
98
+ turn INTEGER NOT NULL,
99
+ at INTEGER NOT NULL,
100
+ user_text TEXT NOT NULL,
101
+ assistant_text TEXT NOT NULL,
102
+ metadata_json TEXT NOT NULL DEFAULT '{}',
103
+ created_at INTEGER NOT NULL
104
+ );
105
+ CREATE INDEX IF NOT EXISTS idx_turn_pairs_scope ON turn_pairs(soul_id, user_id, at DESC);
106
+ -- Idempotency: one exchange per (session, turn). A repeat is ignored, not merged.
107
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_turn_pairs_turn
108
+ ON turn_pairs(soul_id, user_id, session_id, turn);
90
109
  `;
91
110
  const sleepSync = (ms) => {
92
111
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -348,11 +367,77 @@ export function openSqlite(file) {
348
367
  .get(soulId, userId);
349
368
  return Number(row.n);
350
369
  },
370
+ putTurnPair(pair) {
371
+ // INSERT OR IGNORE + the unique index is the whole idempotency story:
372
+ // one exchange per (session, turn), repeats are dropped, never merged.
373
+ withRetry(() => db
374
+ .prepare(`INSERT OR IGNORE INTO turn_pairs
375
+ (id, soul_id, user_id, session_id, turn, at, user_text, assistant_text,
376
+ metadata_json, created_at)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
378
+ .run(pair.id, pair.soulId, pair.userId, pair.sessionId, pair.turn, pair.at, pair.userText, pair.assistantText, JSON.stringify(pair.metadata), pair.createdAt));
379
+ },
380
+ recallTurnPairs(spec) {
381
+ // No cap by default: every related exchange is returned (bounded only by
382
+ // RECALL_CANDIDATES, which limits rows read, not rows injected).
383
+ const limit = spec.limit ?? RECALL_CANDIDATES;
384
+ const rows = db
385
+ .prepare(`SELECT * FROM turn_pairs
386
+ WHERE soul_id = ? AND user_id = ? AND at >= ?
387
+ ORDER BY at DESC LIMIT ?`)
388
+ .all(spec.soulId, spec.userId, spec.since ?? 0, Math.max(limit, RECALL_CANDIDATES));
389
+ const pairs = rows.map(rowToTurnPair);
390
+ const query = spec.query?.trim();
391
+ if (!query)
392
+ return pairs.slice(0, limit);
393
+ const qTokens = tokenize(query);
394
+ if (qTokens.length === 0)
395
+ return pairs.slice(0, limit);
396
+ const docTokens = pairs.map(pairTokens);
397
+ const stats = buildCorpusStats(docTokens);
398
+ return pairs
399
+ .map((pair, i) => ({ pair, score: bm25(qTokens, docTokens[i], stats) }))
400
+ .filter((scored) => scored.score > 0)
401
+ .sort((a, b) => b.score - a.score || b.pair.at - a.pair.at)
402
+ .slice(0, limit)
403
+ .map((scored) => scored.pair);
404
+ },
405
+ countTurnPairs(soulId, userId) {
406
+ const row = db
407
+ .prepare('SELECT COUNT(*) AS n FROM turn_pairs WHERE soul_id = ? AND user_id = ?')
408
+ .get(soulId, userId);
409
+ return Number(row.n);
410
+ },
351
411
  close() {
352
412
  db.close();
353
413
  },
354
414
  };
355
415
  }
416
+ /** Tokens of one exchange. Both sides are searchable; neither is weighted. */
417
+ function pairTokens(pair) {
418
+ return [...tokenize(pair.userText), ...tokenize(pair.assistantText)];
419
+ }
420
+ function rowToTurnPair(row) {
421
+ let metadata = {};
422
+ try {
423
+ metadata = JSON.parse(String(row.metadata_json ?? '{}'));
424
+ }
425
+ catch {
426
+ metadata = {};
427
+ }
428
+ return {
429
+ id: String(row.id),
430
+ soulId: String(row.soul_id),
431
+ userId: String(row.user_id),
432
+ sessionId: String(row.session_id),
433
+ turn: Number(row.turn),
434
+ at: Number(row.at),
435
+ userText: String(row.user_text),
436
+ assistantText: String(row.assistant_text),
437
+ metadata,
438
+ createdAt: Number(row.created_at),
439
+ };
440
+ }
356
441
  function rowToMemory(row) {
357
442
  return {
358
443
  id: String(row.id),
@@ -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, Correction, Emotion, Identity, Memory, QuerySpec } from './types.ts';
5
+ import type { AffectSignal, Correction, Emotion, Identity, Memory, QuerySpec, TurnPair, TurnPairSpec } from './types.ts';
6
6
  export interface SoulStore {
7
7
  ensureSoul(identity: Identity): void;
8
8
  identity(soulId: string): Identity | null;
@@ -11,6 +11,14 @@ export interface SoulStore {
11
11
  put(memory: Memory): void;
12
12
  /** Active, non-deleted memories visible to `userId` (own private + shared). */
13
13
  query(spec: QuerySpec): Memory[];
14
+ /**
15
+ * Store one exchange. Idempotent on `(soul, user, session, turn)`: a repeat is
16
+ * ignored, never merged — the original text is immutable (RFC 0007).
17
+ */
18
+ putTurnPair(pair: TurnPair): void;
19
+ /** Exchanges visible to `userId`; scored against `spec.query` when given. */
20
+ recallTurnPairs(spec: TurnPairSpec): TurnPair[];
21
+ countTurnPairs(soulId: string, userId: string): number;
14
22
  /** Recall bookkeeping: recallCount++, lastRecalledAt = now. */
15
23
  touch(ids: string[], now?: number): void;
16
24
  /**
@@ -40,6 +40,36 @@ export interface Identity {
40
40
  creator?: string;
41
41
  createdAt: number;
42
42
  }
43
+ /**
44
+ * One exchange: what the user said and what the assistant answered.
45
+ * The unit of memory (RFC 0007). Both sides are original text, never truncated
46
+ * here — injection drops a whole pair rather than cutting one in half.
47
+ */
48
+ export interface TurnPair {
49
+ id: string;
50
+ soulId: string;
51
+ userId: string;
52
+ /** Host session id. Together with `turn` it makes writes idempotent. */
53
+ sessionId: string;
54
+ turn: number;
55
+ /** When the user message arrived. */
56
+ at: number;
57
+ userText: string;
58
+ assistantText: string;
59
+ /** Open container for fields not decided yet (briefs/0010 §8). */
60
+ metadata: Record<string, unknown>;
61
+ createdAt: number;
62
+ }
63
+ /** What a recall call may narrow by. Everything is optional. */
64
+ export interface TurnPairSpec {
65
+ soulId: string;
66
+ userId: string;
67
+ /** Text to score against. Empty means "no query": rank by recency only. */
68
+ query?: string;
69
+ limit?: number;
70
+ /** Only pairs at or after this timestamp. */
71
+ since?: number;
72
+ }
43
73
  /** Six dimensions, each -2..+2. Baseline is slightly positive. */
44
74
  export interface Emotion {
45
75
  passion: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.21",
3
+ "version": "0.0.26",
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",