This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

niri / src / memory.ts
34 kB 1102 lines
1import fs from "fs/promises" 2import path from "path" 3import { createHash } from "crypto" 4import { fileURLToPath } from "url" 5import type { Message } from "./types.js" 6import { getDb } from "./db.js" 7import { recordMetric } from "./metrics.js" 8 9const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home") 10const MEMORIES_DIR = path.join(HOME_DIR, "memories") 11const JOURNAL_DIR = path.join(MEMORIES_DIR, "journal") 12const PEOPLE_DIR = path.join(MEMORIES_DIR, "people") 13const CORE_FILE = path.join(MEMORIES_DIR, "core.md") 14const ALIASES_FILE = path.join(MEMORIES_DIR, "aliases.json") 15 16const MEMORY_RECALL_HEADER = "[memory recall v1]" 17const MEMORY_RECALL_NOTE = 18 "Potentially relevant long-term notes. Use only if helpful; trust newer conversation details if anything conflicts." 19const MEMORY_RECALL_MAX_CHUNKS = 4 20const MEMORY_RECALL_MAX_CHUNKS_HARD_CAP = 8 21const MEMORY_RECALL_MAX_CHARS = 1_500 22const MEMORY_RECALL_PER_EXTRA_PERSON_CHARS = 400 23const MEMORY_QUERY_TOKEN_LIMIT = 12 24const MEMORY_RECALL_COOLDOWN_TURNS = 7 25const SCHEDULED_HEARTBEAT_CONTENT = "Scheduled heartbeat." 26const MEMORY_STOP_WORDS = new Set([ 27 "a", 28 "an", 29 "and", 30 "are", 31 "as", 32 "at", 33 "be", 34 "been", 35 "but", 36 "by", 37 "for", 38 "from", 39 "had", 40 "has", 41 "have", 42 "he", 43 "her", 44 "hers", 45 "him", 46 "his", 47 "i", 48 "if", 49 "in", 50 "into", 51 "is", 52 "it", 53 "its", 54 "me", 55 "my", 56 "of", 57 "on", 58 "or", 59 "our", 60 "she", 61 "that", 62 "the", 63 "their", 64 "them", 65 "there", 66 "they", 67 "this", 68 "to", 69 "up", 70 "us", 71 "was", 72 "we", 73 "were", 74 "with", 75 "you", 76 "your", 77]) 78 79type MemoryKind = "core" | "journal" | "people" 80 81type MemoryDocumentRow = { 82 id: number 83 path: string 84 content_hash: string 85 mtime_ms: number 86 kind?: string 87 title?: string 88} 89 90type MemoryChunkInput = { 91 title: string 92 headingPath: string | null 93 text: string 94 tags: string 95} 96 97type MemoryHit = { 98 chunkId: number 99 path: string 100 kind: MemoryKind 101 documentTitle: string 102 title: string 103 headingPath: string | null 104 text: string 105 rank: number 106} 107 108export type MemorySearchResult = { 109 chunkId: number 110 kind: MemoryKind 111 path: string 112 source: string 113 title: string 114 headingPath: string | null 115 preview: string 116} 117 118type MemoryQueryParts = { 119 sender: string | null 120 source: string | null 121 body: string 122} 123 124type MemorySearchProfile = { 125 normalized: string 126 sender: string | null 127 senderAliases: string[] 128 bodyTokens: string[] 129 bodyPeople: string[] 130 tokens: string[] 131 personQuery: boolean 132 eventQuery: boolean 133} 134 135type MemoryHitSignal = { 136 overlap: number 137 strongOverlap: boolean 138 bodyOverlap: number 139 senderMatch: boolean 140} 141 142export type AliasMap = Record<string, string[]> 143 144function normalizeText(value: string): string { 145 return value.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim() 146} 147 148function trimForPrompt(value: string, maxChars: number): string { 149 if (value.length <= maxChars) return value 150 if (maxChars <= 3) return ".".repeat(maxChars) 151 return `${value.slice(0, maxChars - 3).trimEnd()}...` 152} 153 154function basenameWithoutExt(filePath: string): string { 155 return path.basename(filePath, path.extname(filePath)) 156} 157 158function titleFromPath(filePath: string, fallback: string): string { 159 const base = basenameWithoutExt(filePath).replace(/[-_]+/g, " ").trim() 160 return base ? base : fallback 161} 162 163function detectMemoryKind(filePath: string): MemoryKind | null { 164 if (filePath === CORE_FILE) return "core" 165 if (filePath.startsWith(`${JOURNAL_DIR}${path.sep}`)) return "journal" 166 if (filePath.startsWith(`${PEOPLE_DIR}${path.sep}`)) return "people" 167 return null 168} 169 170async function pathExists(target: string): Promise<boolean> { 171 try { 172 await fs.access(target) 173 return true 174 } catch { 175 return false 176 } 177} 178 179function normalizeHandle(handle: string): string { 180 return handle.trim().replace(/^@+/, "").toLowerCase() 181} 182 183let aliasCache: { mtimeMs: number; map: AliasMap } | null = null 184 185async function loadAliasMap(): Promise<AliasMap> { 186 try { 187 const stat = await fs.stat(ALIASES_FILE) 188 if (aliasCache && aliasCache.mtimeMs === stat.mtimeMs) return aliasCache.map 189 const raw = await fs.readFile(ALIASES_FILE, "utf-8") 190 const parsed = JSON.parse(raw) as unknown 191 const map: AliasMap = {} 192 if (parsed && typeof parsed === "object") { 193 for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { 194 const handle = normalizeHandle(key) 195 if (!handle) continue 196 const list = Array.isArray(value) ? value : [value] 197 const aliases = list 198 .map((v) => (typeof v === "string" ? normalizeHandle(v) : "")) 199 .filter((v) => v && v !== handle) 200 if (aliases.length > 0) map[handle] = Array.from(new Set(aliases)) 201 } 202 } 203 aliasCache = { mtimeMs: stat.mtimeMs, map } 204 return map 205 } catch { 206 aliasCache = { mtimeMs: 0, map: {} } 207 return {} 208 } 209} 210 211async function writeAliasMap(map: AliasMap): Promise<void> { 212 await fs.mkdir(MEMORIES_DIR, { recursive: true }) 213 const sorted: AliasMap = {} 214 for (const key of Object.keys(map).sort()) sorted[key] = [...map[key]!].sort() 215 await fs.writeFile(ALIASES_FILE, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8") 216 aliasCache = null 217} 218 219function resolveAliases(handle: string | null, map: AliasMap): string[] { 220 if (!handle) return [] 221 const seen = new Set<string>([handle]) 222 const out: string[] = [] 223 const queue = [handle] 224 while (queue.length > 0) { 225 const current = queue.shift()! 226 const next = map[current] ?? [] 227 for (const alias of next) { 228 if (seen.has(alias)) continue 229 seen.add(alias) 230 out.push(alias) 231 queue.push(alias) 232 } 233 } 234 return out 235} 236 237export async function listAliases(): Promise<AliasMap> { 238 return loadAliasMap() 239} 240 241export async function setAlias(handle: string, canonical: string): Promise<AliasMap> { 242 const h = normalizeHandle(handle) 243 const c = normalizeHandle(canonical) 244 if (!h || !c) throw new Error("alias handle and canonical must be non-empty") 245 const map = await loadAliasMap() 246 if (h === c) return map 247 const existing = new Set(map[h] ?? []) 248 existing.add(c) 249 map[h] = Array.from(existing) 250 await writeAliasMap(map) 251 return map 252} 253 254export async function removeAlias(handle: string, canonical?: string): Promise<AliasMap> { 255 const h = normalizeHandle(handle) 256 if (!h) throw new Error("alias handle must be non-empty") 257 const map = await loadAliasMap() 258 if (!map[h]) return map 259 if (canonical) { 260 const c = normalizeHandle(canonical) 261 map[h] = map[h]!.filter((entry) => entry !== c) 262 if (map[h]!.length === 0) delete map[h] 263 } else { 264 delete map[h] 265 } 266 await writeAliasMap(map) 267 return map 268} 269 270async function walkMarkdownFiles(root: string): Promise<string[]> { 271 if (!(await pathExists(root))) return [] 272 273 const found: string[] = [] 274 const entries = await fs.readdir(root, { withFileTypes: true }) 275 for (const entry of entries) { 276 const fullPath = path.join(root, entry.name) 277 if (entry.isDirectory()) { 278 found.push(...(await walkMarkdownFiles(fullPath))) 279 continue 280 } 281 if (entry.isFile() && entry.name.endsWith(".md")) found.push(fullPath) 282 } 283 284 return found.sort() 285} 286 287async function listMemoryFiles(): Promise<string[]> { 288 const files: string[] = [] 289 if (await pathExists(CORE_FILE)) files.push(CORE_FILE) 290 files.push(...(await walkMarkdownFiles(JOURNAL_DIR))) 291 files.push(...(await walkMarkdownFiles(PEOPLE_DIR))) 292 return files 293} 294 295function contentHash(content: string): string { 296 return createHash("sha1").update(content).digest("hex") 297} 298 299function chunkLargeSection(text: string, maxChars = 900): string[] { 300 const paragraphs = text 301 .split(/\n\s*\n/g) 302 .map((part) => part.trim()) 303 .filter(Boolean) 304 305 if (paragraphs.length === 0) return [] 306 307 const chunks: string[] = [] 308 let current = "" 309 310 for (const paragraph of paragraphs) { 311 const next = current ? `${current}\n\n${paragraph}` : paragraph 312 if (next.length <= maxChars || current.length === 0) { 313 current = next 314 continue 315 } 316 chunks.push(current) 317 current = paragraph 318 } 319 320 if (current) chunks.push(current) 321 return chunks 322} 323 324function parseMarkdownDocument(filePath: string, content: string): { title: string; chunks: MemoryChunkInput[] } { 325 const lines = content.replace(/\r\n/g, "\n").split("\n") 326 const h1 = lines.find((line) => /^#\s+/.test(line)) 327 const title = h1 ? h1.replace(/^#\s+/, "").trim() : titleFromPath(filePath, "Memory") 328 const headingStack: string[] = [] 329 let sectionLines: string[] = [] 330 let sectionTitle = title 331 const chunks: MemoryChunkInput[] = [] 332 333 const flushSection = () => { 334 const body = sectionLines.join("\n").trim() 335 if (!body) { 336 sectionLines = [] 337 return 338 } 339 340 const headingPath = headingStack.length > 0 ? headingStack.join(" > ") : null 341 const tags = [basenameWithoutExt(filePath), ...headingStack].join(" ").trim() 342 for (const part of chunkLargeSection(body)) { 343 chunks.push({ 344 title: sectionTitle || title, 345 headingPath, 346 text: part, 347 tags, 348 }) 349 } 350 sectionLines = [] 351 } 352 353 for (const line of lines) { 354 const headingMatch = line.match(/^(#{1,6})\s+(.*)$/) 355 if (!headingMatch) { 356 sectionLines.push(line) 357 continue 358 } 359 360 flushSection() 361 362 const level = headingMatch[1]!.length 363 const heading = headingMatch[2]!.trim() 364 if (level === 1) { 365 sectionTitle = heading || title 366 headingStack.length = 0 367 continue 368 } 369 370 while (headingStack.length >= level - 1) headingStack.pop() 371 headingStack.push(heading) 372 sectionTitle = heading || title 373 } 374 375 flushSection() 376 377 if (chunks.length === 0) { 378 const body = content.trim() 379 if (body) { 380 chunks.push({ 381 title, 382 headingPath: null, 383 text: body, 384 tags: basenameWithoutExt(filePath), 385 }) 386 } 387 } 388 389 return { title, chunks } 390} 391 392function memoryRecallCooldownTurns(kind: MemoryKind): number { 393 if (kind === "people" || kind === "journal") return MEMORY_RECALL_COOLDOWN_TURNS 394 return 0 395} 396 397function isCoolingDown(hit: MemoryHit, cooldowns: Record<number, number>, currentTurn: number): boolean { 398 const lastTurn = cooldowns[hit.chunkId] 399 if (typeof lastTurn !== "number") return false 400 return currentTurn - lastTurn < memoryRecallCooldownTurns(hit.kind) 401} 402 403function isMemoryRecallSkippedMessage(content: string): boolean { 404 if (content.startsWith(MEMORY_RECALL_HEADER)) return true 405 if (content.startsWith("[system]")) return true 406 if (content.includes("scan snapshot:") && !content.includes("[discord batch]")) return true 407 return false 408} 409 410function latestMemoryRecallQuery(conversation: Message[]): string | null { 411 for (let i = conversation.length - 1; i >= 0; i -= 1) { 412 const message = conversation[i] 413 if (!message || message.role !== "user") continue 414 if (typeof message.content !== "string") continue 415 416 const content = message.content.trim() 417 if (!content || isMemoryRecallSkippedMessage(content)) continue 418 if (content === SCHEDULED_HEARTBEAT_CONTENT) continue 419 return content 420 } 421 return null 422} 423 424function discordChannelLabel(channelId: string | null, fallbackContext: string | null, isDm: boolean): string { 425 if (isDm) return "DM" 426 427 const fallback = fallbackContext 428 ?.replace(/^context:\s*/i, "") 429 .replace(/\s*\(\d+\)\s*$/, "") 430 .trim() 431 432 if (channelId) { 433 try { 434 const row = getDb() 435 .prepare("select guild_id, guild_name, channel_name, is_dm from discord_channels where channel_id = ?") 436 .get(channelId) as 437 | { 438 guild_id: string | null 439 guild_name: string | null 440 channel_name: string | null 441 is_dm: number 442 } 443 | undefined 444 445 if (row?.is_dm) return "DM" 446 if (row) { 447 const guild = row.guild_name ?? row.guild_id 448 const channel = row.channel_name ?? channelId 449 if (guild && channel) return `${guild}/#${channel}` 450 if (channel) return `#${channel}` 451 } 452 } catch { 453 // If the main db is unavailable in tests or scripts, keep the parsed context. 454 } 455 } 456 457 if (fallback) return fallback 458 return channelId ? `#${channelId}` : "channel" 459} 460 461function conciseDiscordMemoryQuery(raw: string): MemoryQueryParts | null { 462 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 463 if (!/\[discord\/(?:dm|channel)\]/i.test(withoutWakeEnvelope)) return null 464 465 const blocks = withoutWakeEnvelope 466 .split(/\n\s*\n/g) 467 .map((block) => block.trim()) 468 .filter(Boolean) 469 const headerBlock = blocks[0] ?? withoutWakeEnvelope 470 const message = blocks.length > 1 ? blocks.slice(1).join("\n\n").trim() : "" 471 472 const lines = headerBlock.split("\n").map((line) => line.trim()).filter(Boolean) 473 const discordLine = lines.find((line) => /^\[discord\/(?:dm|channel)\]/i.test(line)) ?? "" 474 const contextLine = lines.find((line) => /^context:\s*/i.test(line)) ?? null 475 const isDm = /\[discord\/dm\]/i.test(discordLine) 476 const author = discordLine.match(/@(\S+)/)?.[1] ?? null 477 const context = contextLine?.replace(/^context:\s*/i, "").trim() ?? "" 478 const dmChannelId = context.match(/^DM\s+(\d+)/i)?.[1] ?? null 479 const namedChannelId = context.match(/\((\d+)\)\s*$/)?.[1] ?? null 480 const channelId = dmChannelId ?? namedChannelId 481 const location = discordChannelLabel(channelId, contextLine, isDm) 482 483 if (!author && !location && !message) return null 484 return { 485 sender: author ? normalizeHandle(author) : null, 486 source: location || null, 487 body: message, 488 } 489} 490 491function extractBulletSection(raw: string, label: string): string[] { 492 const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 493 const match = raw.match(new RegExp(`(?:^|\\n)${escapedLabel}:\\n([\\s\\S]*?)(?:\\n\\n[^\\n:]+:|$)`, "i")) 494 if (!match) return [] 495 496 return match[1] 497 .split("\n") 498 .map((line) => line.trim()) 499 .filter((line) => line.startsWith("- ")) 500 .map((line) => line.slice(2).trim()) 501 .filter((line) => line && line !== "(none)") 502} 503 504function conciseDiscordBatchMemoryQuery(raw: string): MemoryQueryParts | null { 505 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 506 if (!/\[discord batch\]/i.test(withoutWakeEnvelope)) return null 507 508 const recent = extractBulletSection(withoutWakeEnvelope, "recent messages") 509 const pending = extractBulletSection(withoutWakeEnvelope, "pending preview") 510 const selected = (recent.length > 0 ? recent : pending).slice(-3) 511 if (selected.length === 0) return null 512 513 const senders: string[] = [] 514 const sources: string[] = [] 515 const bodies: string[] = [] 516 const entryPattern = /^\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*(.*)$/i 517 for (const entry of selected) { 518 const match = entry.match(entryPattern) 519 if (!match) { 520 bodies.push(entry) 521 continue 522 } 523 const [, location, author, body] = match 524 if (author) senders.push(normalizeHandle(author)) 525 if (location) sources.push(location.trim()) 526 if (body) bodies.push(body.trim()) 527 } 528 529 const lastSender = senders.length > 0 ? senders[senders.length - 1]! : null 530 const lastSource = sources.length > 0 ? sources[sources.length - 1]! : null 531 const body = bodies.filter(Boolean).join("\n").trim() 532 533 if (!lastSender && !lastSource && !body) return null 534 return { sender: lastSender, source: lastSource, body } 535} 536 537function memoryQueryForUserMessage(raw: string): MemoryQueryParts { 538 return ( 539 conciseDiscordMemoryQuery(raw) ?? 540 conciseDiscordBatchMemoryQuery(raw) ?? 541 { sender: null, source: null, body: raw } 542 ) 543} 544 545function memoryQueryToString(parts: MemoryQueryParts): string { 546 const pieces = [ 547 parts.sender ? `@${parts.sender}` : null, 548 parts.source, 549 parts.body || null, 550 ].filter((value): value is string => Boolean(value && value.trim())) 551 return pieces.join("\n") 552} 553 554function normalizeBodyText(raw: string): string { 555 return raw 556 .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 557 .replace(/\b\d{6,}\b/g, " ") 558 .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 559 .toLowerCase() 560 .trim() 561} 562 563function tokensFromText(raw: string): string[] { 564 const clean = normalizeBodyText(raw) 565 const tokens = clean 566 .split(/\s+/) 567 .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 568 .filter((token) => token.length >= 2 || /\d{2,}/.test(token)) 569 .filter((token) => !MEMORY_STOP_WORDS.has(token)) 570 571 const unique: string[] = [] 572 const seen = new Set<string>() 573 for (const token of tokens) { 574 if (seen.has(token)) continue 575 seen.add(token) 576 unique.push(token) 577 if (unique.length >= MEMORY_QUERY_TOKEN_LIMIT) break 578 } 579 return unique 580} 581 582function searchTokens(raw: string): string[] { 583 return tokensFromText(raw) 584} 585 586async function knownPeopleHandles(aliasMap: AliasMap): Promise<Set<string>> { 587 const handles = new Set<string>() 588 if (await pathExists(PEOPLE_DIR)) { 589 const entries = await fs.readdir(PEOPLE_DIR, { withFileTypes: true }) 590 for (const entry of entries) { 591 if (!entry.isFile() || !entry.name.endsWith(".md")) continue 592 const base = basenameWithoutExt(entry.name).toLowerCase() 593 if (base) handles.add(base) 594 } 595 } 596 for (const [key, values] of Object.entries(aliasMap)) { 597 handles.add(key) 598 for (const value of values) handles.add(value) 599 } 600 return handles 601} 602 603async function buildSearchProfile(parts: MemoryQueryParts): Promise<MemorySearchProfile> { 604 const aliasMap = await loadAliasMap() 605 const sender = parts.sender ? normalizeHandle(parts.sender) : null 606 const senderAliases = resolveAliases(sender, aliasMap) 607 const bodyTokens = parts.body ? tokensFromText(parts.body) : [] 608 609 const known = await knownPeopleHandles(aliasMap) 610 const inlineMentions = parts.body 611 ? Array.from(parts.body.matchAll(/@([a-z0-9_.-]+)/gi)).map((match) => normalizeHandle(match[1]!)) 612 : [] 613 const bodyPeopleSet = new Set<string>() 614 const senderSet = new Set<string>([sender ?? "", ...senderAliases].filter(Boolean)) 615 for (const token of [...bodyTokens, ...inlineMentions]) { 616 if (!token || senderSet.has(token)) continue 617 if (known.has(token)) bodyPeopleSet.add(token) 618 } 619 const bodyPeople = Array.from(bodyPeopleSet) 620 for (const person of [...bodyPeople]) { 621 for (const alias of resolveAliases(person, aliasMap)) { 622 if (!senderSet.has(alias)) bodyPeopleSet.add(alias) 623 } 624 } 625 const bodyPeopleResolved = Array.from(bodyPeopleSet) 626 627 const combined: string[] = [] 628 const seen = new Set<string>() 629 const push = (value: string | null | undefined) => { 630 if (!value) return 631 const lower = value.toLowerCase() 632 if (seen.has(lower)) return 633 seen.add(lower) 634 combined.push(lower) 635 } 636 push(sender) 637 for (const alias of senderAliases) push(alias) 638 for (const person of bodyPeopleResolved) push(person) 639 for (const token of bodyTokens) push(token) 640 641 const normalized = [sender ? `@${sender}` : "", parts.source ?? "", parts.body ?? ""] 642 .filter(Boolean) 643 .join(" ") 644 .toLowerCase() 645 646 return { 647 normalized, 648 sender, 649 senderAliases, 650 bodyTokens, 651 bodyPeople: bodyPeopleResolved, 652 tokens: combined.slice(0, MEMORY_QUERY_TOKEN_LIMIT), 653 personQuery: 654 Boolean(sender) || 655 bodyPeopleResolved.length > 0 || 656 /\b(who is|who's|tell me about|about)\b/.test(normalized) || 657 /\bname\b/.test(normalized) || 658 /\bfriend\b/.test(normalized), 659 eventQuery: 660 /\b(what happened|when|yesterday|today|tonight|earlier|before|after|restart|restarted|session|wake)\b/.test( 661 normalized, 662 ) || 663 /\b\d{4}-\d{2}-\d{2}\b/.test(normalized) || 664 /\b\d{1,2}\/\d{1,2}(?:\/\d{2,4})?\b/.test(normalized) || 665 /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/.test( 666 normalized, 667 ), 668 } 669} 670 671function buildSearchQuery(profile: MemorySearchProfile): string | null { 672 if (profile.tokens.length === 0) return null 673 return profile.tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" OR ") 674} 675 676async function readMemoryDocumentRows(): Promise<Map<string, MemoryDocumentRow>> { 677 const rows = getDb() 678 .prepare("select id, path, kind, title, content_hash, mtime_ms from memory_documents") 679 .all() as MemoryDocumentRow[] 680 681 return new Map(rows.map((row) => [row.path, row])) 682} 683 684export async function syncMemoryIndex(): Promise<void> { 685 const db = getDb() 686 const files = await listMemoryFiles() 687 const known = await readMemoryDocumentRows() 688 const present = new Set(files) 689 690 const deleteChunksByDocument = db.prepare("delete from memory_chunks where document_id = ?") 691 const insertDocument = db.prepare(` 692 insert into memory_documents (path, kind, title, mtime_ms, content_hash, updated_at) 693 values (@path, @kind, @title, @mtime_ms, @content_hash, datetime('now')) 694 on conflict(path) do update set 695 kind = excluded.kind, 696 title = excluded.title, 697 mtime_ms = excluded.mtime_ms, 698 content_hash = excluded.content_hash, 699 updated_at = datetime('now') 700 `) 701 const selectDocumentId = db.prepare("select id from memory_documents where path = ?") 702 const insertChunk = db.prepare(` 703 insert into memory_chunks (document_id, chunk_index, title, heading_path, chunk_text, tags) 704 values (?, ?, ?, ?, ?, ?) 705 `) 706 const deleteDocumentByPath = db.prepare("delete from memory_documents where path = ?") 707 708 const updates: Array<{ 709 path: string 710 kind: MemoryKind 711 title: string 712 mtimeMs: number 713 hash: string 714 chunks: MemoryChunkInput[] 715 action: "inserted" | "updated" 716 }> = [] 717 718 for (const filePath of files) { 719 const kind = detectMemoryKind(filePath) 720 if (!kind) continue 721 722 const [content, stat] = await Promise.all([fs.readFile(filePath, "utf-8"), fs.stat(filePath)]) 723 const hash = contentHash(content) 724 const previous = known.get(filePath) 725 if (previous && previous.content_hash === hash && previous.mtime_ms === Math.floor(stat.mtimeMs)) continue 726 727 const parsed = parseMarkdownDocument(filePath, content) 728 updates.push({ 729 path: filePath, 730 kind, 731 title: parsed.title, 732 mtimeMs: Math.floor(stat.mtimeMs), 733 hash, 734 chunks: parsed.chunks, 735 action: previous ? "updated" : "inserted", 736 }) 737 } 738 739 const removedPaths: string[] = [] 740 741 db.transaction(() => { 742 for (const item of updates) { 743 insertDocument.run({ 744 path: item.path, 745 kind: item.kind, 746 title: item.title, 747 mtime_ms: item.mtimeMs, 748 content_hash: item.hash, 749 }) 750 const row = selectDocumentId.get(item.path) as { id: number } | undefined 751 if (!row) continue 752 753 deleteChunksByDocument.run(row.id) 754 item.chunks.forEach((chunk, index) => { 755 insertChunk.run(row.id, index, chunk.title, chunk.headingPath, chunk.text, chunk.tags) 756 }) 757 758 console.log( 759 `[memory] ${item.action} kind=${item.kind} chunks=${item.chunks.length} path=${path.relative(HOME_DIR, item.path)}`, 760 ) 761 } 762 763 for (const filePath of known.keys()) { 764 if (present.has(filePath)) continue 765 deleteDocumentByPath.run(filePath) 766 removedPaths.push(filePath) 767 console.log(`[memory] removed path=${path.relative(HOME_DIR, filePath)}`) 768 } 769 })() 770 771 if (updates.length === 0 && removedPaths.length === 0) { 772 return 773 } 774} 775 776function senderHandles(profile: MemorySearchProfile): string[] { 777 const out: string[] = [] 778 if (profile.sender) out.push(profile.sender) 779 for (const alias of profile.senderAliases) out.push(alias) 780 return out 781} 782 783function allPersonHandles(profile: MemorySearchProfile): string[] { 784 const seen = new Set<string>() 785 const out: string[] = [] 786 for (const handle of [...senderHandles(profile), ...profile.bodyPeople]) { 787 if (!handle || seen.has(handle)) continue 788 seen.add(handle) 789 out.push(handle) 790 } 791 return out 792} 793 794function hitMatchesHandle(hit: MemoryHit, handle: string): boolean { 795 const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 796 const pathHaystack = hit.path.toLowerCase() 797 return titleHaystack.includes(handle) || pathHaystack.includes(`/${handle}.md`) 798} 799 800function scoreMemoryHit(hit: MemoryHit, profile: MemorySearchProfile): number { 801 let score = hit.rank 802 803 if (profile.personQuery && !profile.eventQuery) { 804 if (hit.kind === "people") score -= 2 805 if (hit.kind === "core") score -= 0.5 806 if (hit.kind === "journal") score += 1.5 807 } else if (profile.eventQuery && !profile.personQuery) { 808 if (hit.kind === "journal") score -= 1.5 809 if (hit.kind === "people") score += 0.75 810 if (hit.kind === "core") score += 0.25 811 } else { 812 if (hit.kind === "people") score -= 0.5 813 if (hit.kind === "core") score -= 0.25 814 } 815 816 const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 817 if (profile.bodyTokens.some((token) => titleHaystack.includes(token))) score -= 0.35 818 819 const handles = allPersonHandles(profile) 820 if (handles.length > 0) { 821 const fullHaystack = `${titleHaystack} ${hit.text.toLowerCase()}` 822 const pathHaystack = hit.path.toLowerCase() 823 if (handles.some((h) => titleHaystack.includes(h) || pathHaystack.includes(`/${h}.md`))) { 824 score -= 3 825 } else if (handles.some((h) => fullHaystack.includes(h))) { 826 score -= 1 827 } 828 } 829 830 return score 831} 832 833function hitSearchHaystack(hit: MemoryHit): string { 834 return `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)} ${hit.text}`.toLowerCase() 835} 836 837function memoryHitSignal(hit: MemoryHit, profile: MemorySearchProfile): MemoryHitSignal { 838 const haystack = hitSearchHaystack(hit) 839 let overlap = 0 840 let strongOverlap = false 841 let bodyOverlap = 0 842 843 for (const token of profile.tokens) { 844 if (!haystack.includes(token)) continue 845 overlap += 1 846 if (token.length >= 5 || /[0-9]/.test(token)) strongOverlap = true 847 } 848 for (const token of profile.bodyTokens) { 849 if (haystack.includes(token)) bodyOverlap += 1 850 } 851 852 const handles = allPersonHandles(profile) 853 const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 854 const pathHaystack = hit.path.toLowerCase() 855 const senderMatch = 856 handles.length > 0 && 857 handles.some((h) => titleHaystack.includes(h) || pathHaystack.includes(`/${h}.md`) || haystack.includes(h)) 858 859 return { overlap, strongOverlap, bodyOverlap, senderMatch } 860} 861 862function shouldInjectHits(hits: MemoryHit[], profile: MemorySearchProfile): boolean { 863 if (hits.length === 0) return false 864 865 const topSignal = memoryHitSignal(hits[0]!, profile) 866 867 if (profile.sender || profile.bodyPeople.length > 0) { 868 if (topSignal.senderMatch) return true 869 if (topSignal.bodyOverlap >= 2) return true 870 if (topSignal.bodyOverlap >= 1 && topSignal.strongOverlap) return true 871 return false 872 } 873 874 if (profile.eventQuery && !profile.personQuery) { 875 return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "journal") 876 } 877 878 if (profile.personQuery && !profile.eventQuery) { 879 return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "people" || hit.kind === "core") 880 } 881 882 if (topSignal.bodyOverlap >= 2) return true 883 if (topSignal.bodyOverlap >= 1 && topSignal.strongOverlap) return true 884 if (topSignal.overlap >= 2 && topSignal.strongOverlap) return true 885 return false 886} 887 888function searchMemory( 889 profile: MemorySearchProfile, 890 cooldowns: Record<number, number>, 891 currentTurn: number, 892 limit: number, 893): MemoryHit[] { 894 const db = getDb() 895 const query = buildSearchQuery(profile) 896 if (!query) return [] 897 const rows = db 898 .prepare(` 899 select 900 c.id as chunkId, 901 d.path as path, 902 d.kind as kind, 903 d.title as documentTitle, 904 c.title as title, 905 c.heading_path as headingPath, 906 c.chunk_text as text, 907 bm25(memory_chunks_fts, 5.0, 2.0, 1.0, 0.5) as rank 908 from memory_chunks_fts 909 join memory_chunks c on c.id = memory_chunks_fts.rowid 910 join memory_documents d on d.id = c.document_id 911 where memory_chunks_fts match ? 912 order by rank 913 limit ? 914 `) 915 .all(query, Math.max(limit * 8, limit)) as MemoryHit[] 916 917 const ordered = rows.sort((a, b) => scoreMemoryHit(a, profile) - scoreMemoryHit(b, profile)) 918 const pool = 919 profile.personQuery && !profile.eventQuery 920 ? (() => { 921 const structured = ordered.filter((row) => row.kind === "people" || row.kind === "core") 922 return structured.length > 0 ? structured : ordered 923 })() 924 : profile.eventQuery && !profile.personQuery 925 ? (() => { 926 const journal = ordered.filter((row) => row.kind === "journal") 927 return journal.length > 0 ? journal : ordered 928 })() 929 : ordered 930 931 const deduped: MemoryHit[] = [] 932 const seenChunkIds = new Set<number>() 933 const seenPaths = new Set<string>() 934 935 const handles = allPersonHandles(profile) 936 if (handles.length >= 2) { 937 for (const handle of handles) { 938 if (deduped.length >= limit) break 939 const candidate = pool.find( 940 (row) => 941 !seenChunkIds.has(row.chunkId) && 942 !seenPaths.has(row.path) && 943 !isCoolingDown(row, cooldowns, currentTurn) && 944 hitMatchesHandle(row, handle), 945 ) 946 if (!candidate) continue 947 seenChunkIds.add(candidate.chunkId) 948 seenPaths.add(candidate.path) 949 deduped.push(candidate) 950 } 951 } 952 953 for (const row of pool) { 954 if (deduped.length >= limit) break 955 if (seenChunkIds.has(row.chunkId)) continue 956 if (isCoolingDown(row, cooldowns, currentTurn)) continue 957 seenChunkIds.add(row.chunkId) 958 deduped.push(row) 959 } 960 961 return deduped 962} 963 964function formatMemorySource(hit: MemoryHit): string { 965 const relativePath = path.relative(HOME_DIR, hit.path) 966 const pieces = [`[${hit.kind}]`, hit.documentTitle] 967 if (hit.headingPath && hit.headingPath !== hit.documentTitle) pieces.push(hit.headingPath) 968 pieces.push(relativePath) 969 return pieces.join(" / ") 970} 971 972function toMemorySearchResult(hit: MemoryHit): MemorySearchResult { 973 return { 974 chunkId: hit.chunkId, 975 kind: hit.kind, 976 path: hit.path, 977 source: formatMemorySource(hit), 978 title: hit.title, 979 headingPath: hit.headingPath, 980 preview: trimForPrompt(normalizeText(hit.text), 280), 981 } 982} 983 984function buildMemoryRecallMessage(hits: MemoryHit[], maxChars: number): string { 985 const lines = [MEMORY_RECALL_HEADER, MEMORY_RECALL_NOTE, ""] 986 let usedChars = lines.join("\n").length 987 988 for (const hit of hits) { 989 const source = formatMemorySource(hit) 990 const remaining = Math.max(120, maxChars - usedChars - source.length - 10) 991 const body = trimForPrompt(normalizeText(hit.text), Math.min(280, remaining)) 992 const block = `- ${source}\n ${body}` 993 994 if (usedChars + block.length > maxChars && lines.length > 3) break 995 lines.push(block) 996 usedChars += block.length + 1 997 } 998 999 return lines.join("\n").trim() 1000} 1001 1002export async function buildCompletionMessages( 1003 conversation: Message[], 1004 cooldowns: Record<number, number>, 1005 currentTurn: number, 1006): Promise<{ messages: Message[]; recalledChunkIds: number[] }> { 1007 const memoryQuerySource = latestMemoryRecallQuery(conversation) 1008 if (!memoryQuerySource) return { messages: conversation, recalledChunkIds: [] } 1009 const queryParts = memoryQueryForUserMessage(memoryQuerySource) 1010 const memoryQuery = memoryQueryToString(queryParts) 1011 1012 await syncMemoryIndex() 1013 1014 const profile = await buildSearchProfile(queryParts) 1015 if (profile.tokens.length === 0) return { messages: conversation, recalledChunkIds: [] } 1016 1017 const personCount = allPersonHandles(profile).length 1018 const recallLimit = Math.min( 1019 MEMORY_RECALL_MAX_CHUNKS_HARD_CAP, 1020 personCount >= 2 ? MEMORY_RECALL_MAX_CHUNKS + (personCount - 1) : MEMORY_RECALL_MAX_CHUNKS, 1021 ) 1022 const hits = searchMemory(profile, cooldowns, currentTurn, recallLimit) 1023 const aliasInfo = profile.senderAliases.length > 0 ? ` aliases=${profile.senderAliases.join(",")}` : "" 1024 const peopleInfo = profile.bodyPeople.length > 0 ? ` people=${profile.bodyPeople.join(",")}` : "" 1025 const debugTag = `sender=${profile.sender ?? "-"}${aliasInfo}${peopleInfo} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}` 1026 if (hits.length === 0) { 1027 console.log( 1028 `[memory] no-hits query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}`, 1029 ) 1030 return { messages: conversation, recalledChunkIds: [] } 1031 } 1032 if (!shouldInjectHits(hits, profile)) { 1033 console.log( 1034 `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag} reason=weak-match`, 1035 ) 1036 return { messages: conversation, recalledChunkIds: [] } 1037 } 1038 1039 const extraPersons = Math.max(0, personCount - 1) 1040 const recallChars = MEMORY_RECALL_MAX_CHARS + extraPersons * MEMORY_RECALL_PER_EXTRA_PERSON_CHARS 1041 const recallContent = buildMemoryRecallMessage(hits, recallChars) 1042 console.log( 1043 `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}\n${recallContent}`, 1044 ) 1045 1046 recordMetric({ 1047 type: "memory", 1048 query: memoryQuery, 1049 results: hits.map(toMemorySearchResult), 1050 }) 1051 1052 const recallMessage: Message = { 1053 role: "user", 1054 content: recallContent, 1055 } 1056 1057 return { 1058 messages: [...conversation, recallMessage], 1059 recalledChunkIds: hits.map((hit) => hit.chunkId), 1060 } 1061} 1062 1063export function rememberRecalledMemoryChunks( 1064 cooldowns: Record<number, number>, 1065 injectedChunkIds: number[], 1066 currentTurn: number, 1067): Record<number, number> { 1068 const next = { ...cooldowns } 1069 for (const chunkId of injectedChunkIds) next[chunkId] = currentTurn 1070 1071 for (const [chunkId, turn] of Object.entries(next)) { 1072 if (currentTurn - turn >= MEMORY_RECALL_COOLDOWN_TURNS * 2) delete next[Number(chunkId)] 1073 } 1074 1075 return next 1076} 1077 1078export const __memoryTest = { 1079 latestMemoryRecallQuery, 1080 memoryQueryForUserMessage, 1081 memoryQueryToString, 1082 normalizeBodyText, 1083 searchTokens, 1084 buildSearchProfile, 1085 resolveAliases, 1086 normalizeHandle, 1087} 1088 1089export async function searchMemories(rawQuery: string, limit = 5): Promise<MemorySearchResult[]> { 1090 await syncMemoryIndex() 1091 1092 const profile = await buildSearchProfile({ sender: null, source: null, body: rawQuery }) 1093 if (profile.tokens.length === 0) return [] 1094 1095 const results = searchMemory(profile, {}, Number.POSITIVE_INFINITY, Math.max(1, Math.min(limit, 10))).map(toMemorySearchResult) 1096 recordMetric({ 1097 type: "memory", 1098 query: rawQuery, 1099 results, 1100 }) 1101 return results 1102}