my harness for niri
1
fork

Configure Feed

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

better filtering on memory

+480 -67
+1
src/bootstrap.ts
··· 81 81 - \`read_file\`: read a file efficiently without shell+cat 82 82 - \`edit_file\`: edit a file by replacing exact text 83 83 - \`memory_search\`: search your indexed long-term memories from core notes, journal entries, and people files 84 + - \`memory_alias\`: link a Discord/Bluesky handle to a canonical person name (e.g. \`@meowskullz\` → \`ana\`). Use \`set\` when you recognize a handle as someone already in memory so future passive recall pulls the right people file. Use \`list\` to see existing aliases, \`remove\` to undo. 84 85 - \`image_tool\`: attach an image from \`${imageRoot}\` for next-turn vision input 85 86 - \`wait_then_continue\`: wait for a short delay or until the next event arrives, then continue to another turn. accepts \`timeout_ms\` (default 10000, max 600000). use this after a timeout or recoverable error when you still want to keep working — an incoming event (like a DM) will wake you early. 86 87 - \`rest\`: go to sleep and end the session. use this when you're truly done \
+86 -11
src/memory.test.ts
··· 16 16 ) 17 17 }) 18 18 19 - test("memoryQueryForUserMessage extracts recent messages from discord batch", () => { 19 + test("memoryQueryForUserMessage extracts structured parts from discord batch", () => { 20 20 const batch = `[user/discord] [discord batch] 2026-05-01T03:10:50.162Z -> 2026-05-01T03:11:22.198Z 21 21 new_messages=1 channels=1 pending_inbox=0 scope=configured+dm 22 22 auto_seen_timeout=10m auto_demoted=0 ··· 29 29 pending preview: 30 30 - (none)` 31 31 32 - assert.equal( 33 - __memoryTest.memoryQueryForUserMessage(batch), 34 - "@meowskullz channel/staying up till 1 billion oclock/#niri awa", 35 - ) 32 + assert.deepEqual(__memoryTest.memoryQueryForUserMessage(batch), { 33 + sender: "meowskullz", 34 + source: "channel/staying up till 1 billion oclock/#niri", 35 + body: "awa", 36 + }) 37 + }) 38 + 39 + test("memoryQueryForUserMessage parses discord DM envelope into parts", () => { 40 + const dm = `[discord/dm] @meowskullz 41 + context: DM 1234567890 42 + message_id: 9999 43 + timestamp: 2026-05-01T00:00:00Z 44 + action: This is a direct message. Reply if it needs a response. 45 + 46 + thanks` 47 + 48 + const parts = __memoryTest.memoryQueryForUserMessage(dm) 49 + assert.equal(parts.sender, "meowskullz") 50 + assert.equal(parts.source, "DM") 51 + assert.equal(parts.body, "thanks") 52 + }) 53 + 54 + test("buildSearchProfile uses sender as primary signal and drops source", async () => { 55 + const profile = await __memoryTest.buildSearchProfile({ 56 + sender: "meowskullz", 57 + source: "DM", 58 + body: "thanks", 59 + }) 60 + 61 + assert.equal(profile.sender, "meowskullz") 62 + assert.equal(profile.personQuery, true) 63 + assert.deepEqual(profile.bodyTokens, ["thanks"]) 64 + assert.ok(profile.tokens.includes("meowskullz")) 65 + assert.ok(profile.tokens.includes("thanks")) 66 + assert.ok(!profile.tokens.includes("dm"), "source label should not become a search token") 67 + }) 68 + 69 + test("resolveAliases follows transitive mappings without cycles", () => { 70 + const map = { 71 + meowskullz: ["ana"], 72 + ana: ["ana_canonical"], 73 + foo: ["meowskullz"], 74 + } 75 + assert.deepEqual(__memoryTest.resolveAliases("meowskullz", map), ["ana", "ana_canonical"]) 76 + assert.deepEqual(__memoryTest.resolveAliases("foo", map), ["meowskullz", "ana", "ana_canonical"]) 77 + assert.deepEqual(__memoryTest.resolveAliases(null, map), []) 78 + }) 79 + 80 + test("buildSearchProfile expands sender via alias map", async (t) => { 81 + const fs = await import("node:fs/promises") 82 + const path = await import("node:path") 83 + const url = await import("node:url") 84 + const memoriesDir = path.resolve(url.fileURLToPath(import.meta.url), "../../home/memories") 85 + const aliasFile = path.join(memoriesDir, "aliases.json") 86 + const had = await fs.readFile(aliasFile, "utf-8").catch(() => null) 87 + 88 + await fs.mkdir(memoriesDir, { recursive: true }) 89 + await fs.writeFile(aliasFile, JSON.stringify({ meowskullz: ["ana"] }), "utf-8") 90 + 91 + t.after(async () => { 92 + if (had !== null) await fs.writeFile(aliasFile, had, "utf-8") 93 + else await fs.rm(aliasFile, { force: true }) 94 + }) 95 + 96 + const profile = await __memoryTest.buildSearchProfile({ 97 + sender: "meowskullz", 98 + source: "DM", 99 + body: "thanks", 100 + }) 101 + 102 + assert.deepEqual(profile.senderAliases, ["ana"]) 103 + assert.ok(profile.tokens.includes("ana")) 36 104 }) 37 105 38 - test("searchTokens keeps meaningful discord batch terms", () => { 39 - const batchQuery = "@meowskullz channel/staying up till 1 billion oclock/#niri awa" 106 + test("buildSearchProfile detects people mentioned in body", async () => { 107 + const profile = await __memoryTest.buildSearchProfile({ 108 + sender: "meowskullz", 109 + source: "DM", 110 + body: "patpat, who is rea", 111 + }) 40 112 41 - assert.deepEqual(__memoryTest.searchTokens(batchQuery), [ 42 - "meowskullz", 43 - "channel", 113 + assert.ok(profile.bodyPeople.includes("rea"), `expected rea in bodyPeople, got ${JSON.stringify(profile.bodyPeople)}`) 114 + assert.ok(profile.tokens.includes("rea")) 115 + assert.equal(profile.personQuery, true) 116 + }) 117 + 118 + test("searchTokens keeps meaningful body terms", () => { 119 + assert.deepEqual(__memoryTest.searchTokens("staying up till 1 billion oclock awa"), [ 44 120 "staying", 45 121 "till", 46 122 "billion", 47 123 "oclock", 48 - "niri", 49 124 "awa", 50 125 ]) 51 126 })
+339 -55
src/memory.ts
··· 11 11 const JOURNAL_DIR = path.join(MEMORIES_DIR, "journal") 12 12 const PEOPLE_DIR = path.join(MEMORIES_DIR, "people") 13 13 const CORE_FILE = path.join(MEMORIES_DIR, "core.md") 14 + const ALIASES_FILE = path.join(MEMORIES_DIR, "aliases.json") 14 15 15 16 const MEMORY_RECALL_HEADER = "[memory recall v1]" 16 17 const MEMORY_RECALL_NOTE = 17 18 "Potentially relevant long-term notes. Use only if helpful; trust newer conversation details if anything conflicts." 18 19 const MEMORY_RECALL_MAX_CHUNKS = 4 20 + const MEMORY_RECALL_MAX_CHUNKS_HARD_CAP = 8 19 21 const MEMORY_RECALL_MAX_CHARS = 1_500 22 + const MEMORY_RECALL_PER_EXTRA_PERSON_CHARS = 400 20 23 const MEMORY_QUERY_TOKEN_LIMIT = 12 21 24 const MEMORY_RECALL_COOLDOWN_TURNS = 7 22 25 const SCHEDULED_HEARTBEAT_CONTENT = "Scheduled heartbeat." ··· 112 115 preview: string 113 116 } 114 117 118 + type MemoryQueryParts = { 119 + sender: string | null 120 + source: string | null 121 + body: string 122 + } 123 + 115 124 type MemorySearchProfile = { 116 125 normalized: string 126 + sender: string | null 127 + senderAliases: string[] 128 + bodyTokens: string[] 129 + bodyPeople: string[] 117 130 tokens: string[] 118 131 personQuery: boolean 119 132 eventQuery: boolean ··· 122 135 type MemoryHitSignal = { 123 136 overlap: number 124 137 strongOverlap: boolean 138 + bodyOverlap: number 139 + senderMatch: boolean 125 140 } 141 + 142 + export type AliasMap = Record<string, string[]> 126 143 127 144 function normalizeText(value: string): string { 128 145 return value.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim() ··· 159 176 } 160 177 } 161 178 179 + function normalizeHandle(handle: string): string { 180 + return handle.trim().replace(/^@+/, "").toLowerCase() 181 + } 182 + 183 + let aliasCache: { mtimeMs: number; map: AliasMap } | null = null 184 + 185 + async 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 + 211 + async 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 + 219 + function 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 + 237 + export async function listAliases(): Promise<AliasMap> { 238 + return loadAliasMap() 239 + } 240 + 241 + export 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 + 254 + export 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 + 162 270 async function walkMarkdownFiles(root: string): Promise<string[]> { 163 271 if (!(await pathExists(root))) return [] 164 272 ··· 350 458 return channelId ? `#${channelId}` : "channel" 351 459 } 352 460 353 - function conciseDiscordMemoryQuery(raw: string): string | null { 461 + function conciseDiscordMemoryQuery(raw: string): MemoryQueryParts | null { 354 462 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 355 463 if (!/\[discord\/(?:dm|channel)\]/i.test(withoutWakeEnvelope)) return null 356 464 ··· 365 473 const discordLine = lines.find((line) => /^\[discord\/(?:dm|channel)\]/i.test(line)) ?? "" 366 474 const contextLine = lines.find((line) => /^context:\s*/i.test(line)) ?? null 367 475 const isDm = /\[discord\/dm\]/i.test(discordLine) 368 - const author = discordLine.match(/@(\S+)/)?.[1] 476 + const author = discordLine.match(/@(\S+)/)?.[1] ?? null 369 477 const context = contextLine?.replace(/^context:\s*/i, "").trim() ?? "" 370 478 const dmChannelId = context.match(/^DM\s+(\d+)/i)?.[1] ?? null 371 479 const namedChannelId = context.match(/\((\d+)\)\s*$/)?.[1] ?? null 372 480 const channelId = dmChannelId ?? namedChannelId 373 481 const location = discordChannelLabel(channelId, contextLine, isDm) 374 482 375 - const parts = [ 376 - author ? `@${author}` : null, 377 - location, 378 - message || null, 379 - ].filter((part): part is string => Boolean(part?.trim())) 380 - 381 - return parts.length ? parts.join("\n") : null 483 + if (!author && !location && !message) return null 484 + return { 485 + sender: author ? normalizeHandle(author) : null, 486 + source: location || null, 487 + body: message, 488 + } 382 489 } 383 490 384 491 function extractBulletSection(raw: string, label: string): string[] { ··· 394 501 .filter((line) => line && line !== "(none)") 395 502 } 396 503 397 - function conciseDiscordBatchMemoryQuery(raw: string): string | null { 504 + function conciseDiscordBatchMemoryQuery(raw: string): MemoryQueryParts | null { 398 505 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 399 506 if (!/\[discord batch\]/i.test(withoutWakeEnvelope)) return null 400 507 ··· 403 510 const selected = (recent.length > 0 ? recent : pending).slice(-3) 404 511 if (selected.length === 0) return null 405 512 406 - const normalized = selected.map((entry) => 407 - entry.replace(/^\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*/i, "@$2 $1 ").trim(), 408 - ) 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 + } 409 528 410 - return normalized.join("\n") 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 } 411 535 } 412 536 413 - function memoryQueryForUserMessage(raw: string): string { 414 - return conciseDiscordMemoryQuery(raw) ?? conciseDiscordBatchMemoryQuery(raw) ?? raw 537 + function memoryQueryForUserMessage(raw: string): MemoryQueryParts { 538 + return ( 539 + conciseDiscordMemoryQuery(raw) ?? 540 + conciseDiscordBatchMemoryQuery(raw) ?? 541 + { sender: null, source: null, body: raw } 542 + ) 415 543 } 416 544 417 - function normalizeSearchInput(raw: string): string { 418 - const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 419 - 420 - const blocks = withoutWakeEnvelope 421 - .split(/\n\s*\n/g) 422 - .map((block) => block.trim()) 423 - .filter(Boolean) 424 - 425 - const bodyCandidate = blocks.length > 0 ? blocks[blocks.length - 1]! : withoutWakeEnvelope 545 + function 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 + } 426 553 427 - return bodyCandidate 428 - .replace(/^\[discord\/[^\]]+\]\s*@\S+\s+in\s+\d+(?:\s+\(\d+\))?\s*/gi, "") 429 - .replace(/^\[[^\]]+\]\s*/g, "") 554 + function normalizeBodyText(raw: string): string { 555 + return raw 430 556 .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 431 557 .replace(/\b\d{6,}\b/g, " ") 432 558 .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") ··· 434 560 .trim() 435 561 } 436 562 437 - function searchTokens(raw: string): string[] { 438 - const clean = normalizeSearchInput(raw) 439 - 563 + function tokensFromText(raw: string): string[] { 564 + const clean = normalizeBodyText(raw) 440 565 const tokens = clean 441 566 .split(/\s+/) 442 567 .map((token) => token.replace(/^['-]+|['-]+$/g, "")) ··· 451 576 unique.push(token) 452 577 if (unique.length >= MEMORY_QUERY_TOKEN_LIMIT) break 453 578 } 454 - 455 579 return unique 456 580 } 457 581 458 - function buildSearchProfile(raw: string): MemorySearchProfile { 459 - const normalized = normalizeSearchInput(raw) 460 - const tokens = searchTokens(raw) 582 + function searchTokens(raw: string): string[] { 583 + return tokensFromText(raw) 584 + } 585 + 586 + async 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 + 603 + async 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() 461 645 462 646 return { 463 647 normalized, 464 - tokens, 648 + sender, 649 + senderAliases, 650 + bodyTokens, 651 + bodyPeople: bodyPeopleResolved, 652 + tokens: combined.slice(0, MEMORY_QUERY_TOKEN_LIMIT), 465 653 personQuery: 654 + Boolean(sender) || 655 + bodyPeopleResolved.length > 0 || 466 656 /\b(who is|who's|tell me about|about)\b/.test(normalized) || 467 657 /\bname\b/.test(normalized) || 468 658 /\bfriend\b/.test(normalized), ··· 583 773 } 584 774 } 585 775 776 + function 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 + 783 + function 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 + 794 + function 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 + 586 800 function scoreMemoryHit(hit: MemoryHit, profile: MemorySearchProfile): number { 587 801 let score = hit.rank 588 802 ··· 600 814 } 601 815 602 816 const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 603 - if (profile.tokens.some((token) => titleHaystack.includes(token))) score -= 0.35 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 + } 604 829 605 830 return score 606 831 } ··· 613 838 const haystack = hitSearchHaystack(hit) 614 839 let overlap = 0 615 840 let strongOverlap = false 841 + let bodyOverlap = 0 616 842 617 843 for (const token of profile.tokens) { 618 844 if (!haystack.includes(token)) continue 619 845 overlap += 1 620 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 621 850 } 622 851 623 - return { overlap, strongOverlap } 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 } 624 860 } 625 861 626 862 function shouldInjectHits(hits: MemoryHit[], profile: MemorySearchProfile): boolean { ··· 628 864 629 865 const topSignal = memoryHitSignal(hits[0]!, profile) 630 866 631 - if (profile.personQuery && !profile.eventQuery) { 632 - return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "people" || hit.kind === "core") 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 633 872 } 634 873 635 874 if (profile.eventQuery && !profile.personQuery) { 636 875 return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "journal") 637 876 } 638 877 639 - if (topSignal.overlap >= 2) return true 640 - if (topSignal.overlap >= 1 && topSignal.strongOverlap) return true 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 641 885 return false 642 886 } 643 887 ··· 686 930 687 931 const deduped: MemoryHit[] = [] 688 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 + 689 953 for (const row of pool) { 954 + if (deduped.length >= limit) break 690 955 if (seenChunkIds.has(row.chunkId)) continue 691 956 if (isCoolingDown(row, cooldowns, currentTurn)) continue 692 957 seenChunkIds.add(row.chunkId) 693 958 deduped.push(row) 694 - if (deduped.length >= limit) break 695 959 } 696 960 697 961 return deduped ··· 717 981 } 718 982 } 719 983 720 - function buildMemoryRecallMessage(hits: MemoryHit[]): string { 984 + function buildMemoryRecallMessage(hits: MemoryHit[], maxChars: number): string { 721 985 const lines = [MEMORY_RECALL_HEADER, MEMORY_RECALL_NOTE, ""] 722 986 let usedChars = lines.join("\n").length 723 987 724 988 for (const hit of hits) { 725 989 const source = formatMemorySource(hit) 726 - const remaining = Math.max(120, MEMORY_RECALL_MAX_CHARS - usedChars - source.length - 10) 990 + const remaining = Math.max(120, maxChars - usedChars - source.length - 10) 727 991 const body = trimForPrompt(normalizeText(hit.text), Math.min(280, remaining)) 728 992 const block = `- ${source}\n ${body}` 729 993 730 - if (usedChars + block.length > MEMORY_RECALL_MAX_CHARS && lines.length > 3) break 994 + if (usedChars + block.length > maxChars && lines.length > 3) break 731 995 lines.push(block) 732 996 usedChars += block.length + 1 733 997 } ··· 742 1006 ): Promise<{ messages: Message[]; recalledChunkIds: number[] }> { 743 1007 const memoryQuerySource = latestMemoryRecallQuery(conversation) 744 1008 if (!memoryQuerySource) return { messages: conversation, recalledChunkIds: [] } 745 - const memoryQuery = memoryQueryForUserMessage(memoryQuerySource) 1009 + const queryParts = memoryQueryForUserMessage(memoryQuerySource) 1010 + const memoryQuery = memoryQueryToString(queryParts) 746 1011 747 1012 await syncMemoryIndex() 748 1013 749 - const profile = buildSearchProfile(memoryQuery) 1014 + const profile = await buildSearchProfile(queryParts) 750 1015 if (profile.tokens.length === 0) return { messages: conversation, recalledChunkIds: [] } 751 1016 752 - const hits = searchMemory(profile, cooldowns, currentTurn, MEMORY_RECALL_MAX_CHUNKS) 753 - if (hits.length === 0) return { messages: conversation, recalledChunkIds: [] } 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 + } 754 1032 if (!shouldInjectHits(hits, profile)) { 755 1033 console.log( 756 - `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery} reason=weak-match`, 1034 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag} reason=weak-match`, 757 1035 ) 758 1036 return { messages: conversation, recalledChunkIds: [] } 759 1037 } 760 1038 761 - const recallContent = buildMemoryRecallMessage(hits) 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) 762 1042 console.log( 763 - `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}\n${recallContent}`, 1043 + `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}\n${recallContent}`, 764 1044 ) 765 1045 766 1046 recordMetric({ ··· 798 1078 export const __memoryTest = { 799 1079 latestMemoryRecallQuery, 800 1080 memoryQueryForUserMessage, 801 - normalizeSearchInput, 1081 + memoryQueryToString, 1082 + normalizeBodyText, 802 1083 searchTokens, 1084 + buildSearchProfile, 1085 + resolveAliases, 1086 + normalizeHandle, 803 1087 } 804 1088 805 1089 export async function searchMemories(rawQuery: string, limit = 5): Promise<MemorySearchResult[]> { 806 1090 await syncMemoryIndex() 807 1091 808 - const profile = buildSearchProfile(rawQuery) 1092 + const profile = await buildSearchProfile({ sender: null, source: null, body: rawQuery }) 809 1093 if (profile.tokens.length === 0) return [] 810 1094 811 1095 const results = searchMemory(profile, {}, Number.POSITIVE_INFINITY, Math.max(1, Math.min(limit, 10))).map(toMemorySearchResult)
+27 -1
src/runner/loop-tool-registry.ts
··· 11 11 sendDiscordMessage, 12 12 setDiscordChannelNote, 13 13 } from "../discord/state.js" 14 - import { searchMemories } from "../memory.js" 14 + import { listAliases, removeAlias, searchMemories, setAlias } from "../memory.js" 15 15 import { emit } from "../stream.js" 16 16 import type { ToolHandler } from "./loop-shared.js" 17 17 import { pushToolMessage, recordToolResult, runStandardTool, toolError } from "./loop-tool-runtime.js" ··· 115 115 2, 116 116 ), 117 117 emptyFallback: '{"query":"","results":[]}', 118 + }), 119 + 120 + memory_alias: (ctx) => 121 + runStandardTool(ctx, { 122 + name: "memory_alias", 123 + logArgKeys: ["action", "handle", "canonical"] as const, 124 + runArgKeys: ["action", "handle", "canonical"] as const, 125 + run: async (action, handle, canonical) => { 126 + const op = String(action ?? "").toLowerCase() 127 + if (op === "list") { 128 + return JSON.stringify({ ok: true, aliases: await listAliases() }, null, 2) 129 + } 130 + if (op === "set") { 131 + if (!handle || !canonical) { 132 + return JSON.stringify({ ok: false, error: "set requires handle and canonical" }) 133 + } 134 + const map = await setAlias(String(handle), String(canonical)) 135 + return JSON.stringify({ ok: true, aliases: map }, null, 2) 136 + } 137 + if (op === "remove") { 138 + if (!handle) return JSON.stringify({ ok: false, error: "remove requires handle" }) 139 + const map = await removeAlias(String(handle), canonical ? String(canonical) : undefined) 140 + return JSON.stringify({ ok: true, aliases: map }, null, 2) 141 + } 142 + return JSON.stringify({ ok: false, error: `unknown action: ${op}` }) 143 + }, 118 144 }), 119 145 120 146 image_tool: async ({ convId, state, call, args }) => {
+27
src/runner/util.ts
··· 238 238 { 239 239 type: "function", 240 240 function: { 241 + name: "memory_alias", 242 + description: 243 + "Manage handle aliases used for memory recall. When you see someone using a Discord/Bluesky handle that you recognize as an existing person in memory, set an alias so future messages from that handle pull the right people/core memories. Example: set @meowskullz = ana so DMs from meowskullz recall ana's people file.", 244 + parameters: { 245 + type: "object", 246 + properties: { 247 + action: { 248 + type: "string", 249 + enum: ["set", "remove", "list"], 250 + description: "set links a handle to a canonical name; remove unlinks; list returns all current aliases.", 251 + }, 252 + handle: { 253 + type: "string", 254 + description: "The handle to alias, e.g. \"meowskullz\" or \"@meowskullz\". Required for set/remove.", 255 + }, 256 + canonical: { 257 + type: "string", 258 + description: "The canonical name the handle maps to, e.g. \"ana\". Required for set; optional for remove (omit to clear all aliases for the handle).", 259 + }, 260 + }, 261 + required: ["action"], 262 + }, 263 + }, 264 + }, 265 + { 266 + type: "function", 267 + function: { 241 268 name: "image_tool", 242 269 description: 243 270 `Attach an image from ${IMAGE_ROOT_HINT} so it is injected as a multimodal user message on the next model turn. Use this after creating/downloading an image with shell.`,