File size: 7,178 Bytes
5871090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
/**
 * Pure helpers for the long-term memory pipeline. Kept free of any
 * `@workspace/db` import so they can be loaded — and unit-tested —
 * without spinning up a Postgres pool.
 *
 * The DB-aware service in `memory.ts` re-exports everything here.
 */
import { createHash } from "node:crypto";

// ---------- hard caps (server-enforced)
export const MEMORY_HARD_CAPS = {
  facts: 100,
  tokensPerTurn: 2000,
  newPerTurn: 5,
  contentChars: 500,
} as const;

export const VALID_KINDS: ReadonlySet<string> = new Set([
  "preference",
  "fact",
  "interest",
  "domain",
  "terminology",
  "summary",
]);

export interface MemoryConfig {
  enabled: boolean;
  auto_extract: boolean;
  max_facts: number;
  max_tokens_per_turn: number;
}

export interface PublicMemoryFact {
  id: string;
  kind: string;
  content: string;
  confidence: number;
  salience: number;
  source: "auto" | "manual";
  source_message_id: string | null;
  conversation_id: string | null;
  use_count: number;
  archived: boolean;
  created_at: string;
  updated_at: string;
  last_used_at: string | null;
}

export interface MemoryInjection {
  facts: PublicMemoryFact[];
  fact_ids: string[];
  injected_tokens: number;
  text: string | null;
}

// ---------- helpers

/** ~4 chars per token is good enough for budget bookkeeping. */
export function estimateTokens(s: string): number {
  if (!s) return 0;
  return Math.ceil(s.length / 4);
}

/** Lowercased, whitespace-collapsed, punctuation-stripped dedupe key. */
export function normalize(s: string): string {
  return (s || "")
    .toLowerCase()
    .replace(/[\p{P}\p{S}]+/gu, " ")
    .replace(/\s+/g, " ")
    .trim();
}

/** Stable SHA-256 hex of `normalize(content)` — used as the canonical
 *  dedupe key alongside the textual normalized column. */
export function contentHash(content: string): string {
  return createHash("sha256").update(normalize(content)).digest("hex");
}

// ---------- ranker prompt fragments

export const MEMORY_HEADER =
  "Long-term memory about the user (carry across conversations):\n";
export const MEMORY_INSTRUCTION_USE =
  "\n\nUse these facts naturally; do not announce that you are using stored memory unless asked.";
export const MEMORY_INSTRUCTION_EXTRACT =
  " If the user shares a new stable preference / identity / workflow / important context, " +
  "emit it as `<memory_fact>{\"kind\":\"preference|fact|interest|domain|terminology|summary\",\"content\":\"...\",\"confidence\":0.0-1.0,\"salience\":0.0-1.0}</memory_fact>` " +
  "on its own line so it is remembered for future turns. Skip emitting facts that are already listed above.";
export const MEMORY_BOOTSTRAP_EXTRACT =
  "Long-term memory is enabled for this user but currently empty. " +
  "If the user shares a stable preference, durable fact, recurring interest, research domain, terminology, or important context summary, " +
  "emit it on its own line as `<memory_fact>{\"kind\":\"preference|fact|interest|domain|terminology|summary\",\"content\":\"...\",\"confidence\":0.0-1.0,\"salience\":0.0-1.0}</memory_fact>` " +
  "so it is remembered for future turns.";

const STOPWORDS: ReadonlySet<string> = new Set([
  "the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "at",
  "with", "is", "are", "was", "were", "be", "been", "being", "this",
  "that", "these", "those", "it", "its", "as", "by", "from", "i", "you",
  "we", "they", "he", "she", "do", "does", "did", "have", "has", "had",
  "what", "which", "who", "how", "why", "when", "where", "can", "should",
  "would", "will", "if", "but", "not",
  "的", "了", "是", "我", "你", "他", "她", "它", "和", "在", "有",
  "也", "都", "就", "要", "吗", "呢", "吧", "把", "对",
]);

function tokenizeForOverlap(s: string): Set<string> {
  const out = new Set<string>();
  if (!s) return out;
  // Western words (≥3 chars) and CJK bigrams
  const lower = s.toLowerCase();
  const wordRe = /[a-z][a-z0-9_-]{2,}/g;
  let m: RegExpExecArray | null;
  while ((m = wordRe.exec(lower))) {
    if (!STOPWORDS.has(m[0])) out.add(m[0]);
  }
  const cjkRe = /[\u4e00-\u9fff]+/g;
  while ((m = cjkRe.exec(lower))) {
    const seg = m[0];
    for (let i = 0; i < seg.length - 1; i++) {
      const bi = seg.slice(i, i + 2);
      if (!STOPWORDS.has(bi)) out.add(bi);
    }
    if (seg.length === 1) out.add(seg);
  }
  return out;
}

/**
 * Pure ranker + token-budget packer. Exported so it can be unit-tested
 * without a database. Given a list of candidate facts, scores them by
 * `salience * 0.6 + recency * 0.2 + keyword_overlap * 0.5`, sorts
 * descending, and packs as many as fit within
 * `min(cfg.max_tokens_per_turn, MEMORY_HARD_CAPS.tokensPerTurn)` after
 * subtracting the header and instruction overhead.
 */
export function rankAndPackFacts(
  facts: PublicMemoryFact[],
  currentText: string,
  cfg: MemoryConfig,
): MemoryInjection {
  if (!cfg.enabled) {
    return { facts: [], fact_ids: [], injected_tokens: 0, text: null };
  }
  if (!facts.length) {
    if (cfg.auto_extract) {
      return {
        facts: [],
        fact_ids: [],
        injected_tokens: estimateTokens(MEMORY_BOOTSTRAP_EXTRACT),
        text: MEMORY_BOOTSTRAP_EXTRACT,
      };
    }
    return { facts: [], fact_ids: [], injected_tokens: 0, text: null };
  }
  const queryTokens = tokenizeForOverlap(currentText);
  const now = Date.now();
  const scored = facts.map((f) => {
    const factTokens = tokenizeForOverlap(f.content);
    let overlap = 0;
    for (const q of queryTokens) if (factTokens.has(q)) overlap += 1;
    const overlapBoost = queryTokens.size
      ? overlap / Math.max(1, Math.min(queryTokens.size, factTokens.size))
      : 0;
    const updatedAtMs = Date.parse(f.updated_at);
    const ageDays = (now - updatedAtMs) / (24 * 3600 * 1000);
    // Half-life ~ 30 days for recency (range 0.5 .. 1.0)
    const recency = 0.5 + 0.5 / (1 + ageDays / 30);
    const score = f.salience * 0.6 + recency * 0.2 + overlapBoost * 0.5;
    return { fact: f, score };
  });
  scored.sort((a, b) => b.score - a.score);

  const budget = Math.min(cfg.max_tokens_per_turn, MEMORY_HARD_CAPS.tokensPerTurn);
  const instructionText =
    MEMORY_INSTRUCTION_USE +
    (cfg.auto_extract ? MEMORY_INSTRUCTION_EXTRACT : "");
  const fixedTokens =
    estimateTokens(MEMORY_HEADER) + estimateTokens(instructionText);
  let used = fixedTokens;
  const picked: PublicMemoryFact[] = [];
  for (const { fact } of scored) {
    const line = `- [${fact.kind}] ${fact.content}\n`;
    const cost = estimateTokens(line);
    if (used + cost > budget) continue;
    used += cost;
    picked.push(fact);
    if (picked.length >= 50) break;
  }
  if (!picked.length) {
    if (cfg.auto_extract) {
      return {
        facts: [],
        fact_ids: [],
        injected_tokens: estimateTokens(MEMORY_BOOTSTRAP_EXTRACT),
        text: MEMORY_BOOTSTRAP_EXTRACT,
      };
    }
    return { facts: [], fact_ids: [], injected_tokens: 0, text: null };
  }
  const text =
    MEMORY_HEADER +
    picked.map((f) => `- [${f.kind}] ${f.content}`).join("\n") +
    instructionText;
  return {
    facts: picked,
    fact_ids: picked.map((f) => f.id),
    injected_tokens: used,
    text,
  };
}