This repository has no description
0

Configure Feed

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

simplify memory and modualize discord-state

+3284 -2462
+1 -1
scripts/chat.ts
··· 1 1 import readline from "readline" 2 2 import { createChatClient, type StreamEvent } from "@niri/chat-client" 3 - import { renderMarkdownAnsi } from "./terminal-markdown.js" 3 + import { renderMarkdownAnsi } from "./terminal-markdown" 4 4 5 5 const HOST = process.env.NIRI_HOST ?? "http://localhost" 6 6 const PORT = process.env.PORT ?? "3000"
+3 -3
src/bootstrap.ts
··· 1 1 import fs from "fs/promises" 2 2 import path from "path" 3 - import type { UserMessage, Message } from "./types.js" 4 - import { CONTAINER_USER, HOME_DIR, USE_DOCKER_SHELL } from "./container/config.js" 5 - import { imageRootForModelInput } from "./container/index.js" 3 + import type { UserMessage, Message } from "./types" 4 + import { CONTAINER_USER, HOME_DIR, USE_DOCKER_SHELL } from "./container/config" 5 + import { imageRootForModelInput } from "./container/index" 6 6 7 7 const SOUL_FILE = path.join(HOME_DIR, "soul.md") 8 8 const MISPLACED_SOUL_FILE = path.join(HOME_DIR, "memories", "soul.md")
+4 -4
src/container/index.ts
··· 1 - export { imageRootForModelInput } from "./config.js" 2 - export { closeBash, currentWorkingDirectory, openBash } from "./shell.js" 3 - export { editFile, readFile, readImageForModel, runCommand } from "./tools.js" 4 - export type { EditResult, ModelImageInput, RunRawOptions, ImageToolPayload } from "./types.js" 1 + export { imageRootForModelInput } from "./config" 2 + export { closeBash, currentWorkingDirectory, openBash } from "./shell" 3 + export { editFile, readFile, readImageForModel, runCommand } from "./tools" 4 + export type { EditResult, ModelImageInput, RunRawOptions, ImageToolPayload } from "./types"
+2 -2
src/container/shell.test.ts
··· 3 3 import fs from "node:fs/promises" 4 4 import os from "node:os" 5 5 import path from "node:path" 6 - import { closeBash } from "./shell.js" 7 - import { runCommand } from "./tools.js" 6 + import { closeBash } from "./shell" 7 + import { runCommand } from "./tools" 8 8 9 9 test.afterEach(() => { 10 10 closeBash()
+2 -2
src/container/shell.ts
··· 7 7 DEFAULT_COMMAND_TIMEOUT_MS, 8 8 USE_DOCKER_SHELL, 9 9 normalizeTimeoutMs, 10 - } from "./config.js" 11 - import type { RunRawOptions } from "./types.js" 10 + } from "./config" 11 + import type { RunRawOptions } from "./types" 12 12 13 13 /** 14 14 * Strip ANSI/VT escape sequences and normalize line endings from PTY output.
+3 -3
src/container/tools.ts
··· 9 9 USE_DOCKER_SHELL, 10 10 normalizeTimeoutMs, 11 11 resolveMaxLines, 12 - } from "./config.js" 13 - import { currentWorkingDirectory, runOneOff, runRaw } from "./shell.js" 14 - import type { EditResult, ImageToolPayload, ModelImageInput } from "./types.js" 12 + } from "./config" 13 + import { currentWorkingDirectory, runOneOff, runRaw } from "./shell" 14 + import type { EditResult, ImageToolPayload, ModelImageInput } from "./types" 15 15 16 16 function invokesSudo(command: string): boolean { 17 17 return /(^|[;&|({]\s*)sudo(\s|$)/.test(String(command ?? ""))
+1 -1
src/db.ts
··· 2 2 import fs from "fs" 3 3 import path from "path" 4 4 import * as sqliteVec from "sqlite-vec" 5 - import { HOME_DIR } from "./container/config.js" 5 + import { HOME_DIR } from "./container/config" 6 6 7 7 const DB_PATH = path.join(HOME_DIR, "niri.db") 8 8 export const MEMORY_EMBEDDING_DIMENSIONS = 3072
+432
src/discord/db.ts
··· 1 + /** 2 + * Discord SQLite persistence layer — upserts, meta, and maintenance queries. 3 + * 4 + * @module discord/db 5 + */ 6 + 7 + import { getDb } from "../db" 8 + import { 9 + asBoolean, 10 + asNumber, 11 + asString, 12 + configuredChannelIdSet, 13 + parseChannelIds, 14 + type DiscordChannelRecord, 15 + type DiscordMessageRecord, 16 + } from "./parse" 17 + 18 + // ── channel / message upserts ────────────────────────────────────────── 19 + 20 + /** 21 + * Persists a parsed Discord channel record, creating or updating as needed. 22 + * 23 + * @param record - Channel data to upsert. 24 + */ 25 + export function upsertDiscordChannel(record: DiscordChannelRecord): void { 26 + const db = getDb() 27 + const now = new Date().toISOString() 28 + 29 + db.prepare( 30 + `insert into discord_channels ( 31 + channel_id, guild_id, channel_type, channel_name, guild_name, topic, 32 + is_dm, configured, first_seen_at, last_seen_at, raw_json 33 + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 34 + on conflict(channel_id) do update set 35 + guild_id = coalesce(excluded.guild_id, discord_channels.guild_id), 36 + channel_type = coalesce(excluded.channel_type, discord_channels.channel_type), 37 + channel_name = coalesce(excluded.channel_name, discord_channels.channel_name), 38 + guild_name = coalesce(excluded.guild_name, discord_channels.guild_name), 39 + topic = coalesce(excluded.topic, discord_channels.topic), 40 + is_dm = excluded.is_dm, 41 + configured = max(discord_channels.configured, excluded.configured), 42 + last_seen_at = excluded.last_seen_at, 43 + raw_json = excluded.raw_json`, 44 + ).run( 45 + record.channelId, 46 + record.guildId, 47 + record.channelType, 48 + record.channelName, 49 + record.guildName, 50 + record.topic, 51 + record.isDm ? 1 : 0, 52 + record.configured ? 1 : 0, 53 + now, 54 + now, 55 + record.rawJson, 56 + ) 57 + } 58 + 59 + /** 60 + * Persists a parsed Discord message record, creating or updating as needed. 61 + * 62 + * @param record - Message data to upsert. 63 + */ 64 + export function upsertDiscordMessage(record: DiscordMessageRecord): void { 65 + const db = getDb() 66 + const now = new Date().toISOString() 67 + 68 + db.prepare( 69 + `insert into discord_messages ( 70 + message_id, channel_id, guild_id, channel_type, 71 + author_id, author_username, content, created_at, 72 + is_dm, mentions_bot, is_from_bot, 73 + first_seen_at, last_seen_at, raw_json 74 + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 75 + on conflict(message_id) do update set 76 + channel_id = excluded.channel_id, 77 + guild_id = coalesce(excluded.guild_id, discord_messages.guild_id), 78 + channel_type = coalesce(excluded.channel_type, discord_messages.channel_type), 79 + author_id = excluded.author_id, 80 + author_username = excluded.author_username, 81 + content = excluded.content, 82 + created_at = excluded.created_at, 83 + is_dm = case 84 + when excluded.guild_id is not null then 0 85 + when discord_messages.guild_id is not null and excluded.guild_id is null then discord_messages.is_dm 86 + else excluded.is_dm 87 + end, 88 + mentions_bot = excluded.mentions_bot, 89 + is_from_bot = excluded.is_from_bot, 90 + last_seen_at = excluded.last_seen_at, 91 + raw_json = excluded.raw_json`, 92 + ).run( 93 + record.messageId, 94 + record.channelId, 95 + record.guildId, 96 + record.channelType, 97 + record.authorId, 98 + record.authorUsername, 99 + record.content, 100 + record.createdAt, 101 + record.isDm ? 1 : 0, 102 + record.mentionsBot ? 1 : 0, 103 + record.isFromBot ? 1 : 0, 104 + now, 105 + now, 106 + record.rawJson, 107 + ) 108 + } 109 + 110 + /** 111 + * Creates or updates an inbox item for a given message. 112 + * 113 + * @param messageId - Discord message id (also used as item id). 114 + * @param bucket - Inbox bucket (`"dm"` or `"mention"`). 115 + */ 116 + export function upsertInboxItem(messageId: string, bucket: "dm" | "mention"): void { 117 + const db = getDb() 118 + const now = new Date().toISOString() 119 + 120 + db.prepare( 121 + `insert into discord_items ( 122 + item_id, message_id, bucket, status, 123 + action_taken, first_seen_at, last_seen_at 124 + ) values (?, ?, ?, 'pending', 'none', ?, ?) 125 + on conflict(item_id) do update set 126 + bucket = excluded.bucket, 127 + last_seen_at = excluded.last_seen_at`, 128 + ).run(messageId, messageId, bucket, now, now) 129 + } 130 + 131 + // ── meta key/value ───────────────────────────────────────────────────── 132 + 133 + /** 134 + * Reads a value from the `discord_meta` table. 135 + * 136 + * @param key - Meta key. 137 + * @returns Stored value or `null`. 138 + */ 139 + export function getDiscordMeta(key: string): string | null { 140 + const db = getDb() 141 + const row = db 142 + .prepare(`select value from discord_meta where key = ?`) 143 + .get(key) as { value?: string } | undefined 144 + return row?.value ?? null 145 + } 146 + 147 + /** 148 + * Writes a value to the `discord_meta` table. 149 + * 150 + * @param key - Meta key. 151 + * @param value - Value to store. 152 + */ 153 + export function setDiscordMeta(key: string, value: string): void { 154 + const db = getDb() 155 + const now = new Date().toISOString() 156 + db.prepare( 157 + `insert into discord_meta (key, value, updated_at) 158 + values (?, ?, ?) 159 + on conflict(key) do update set 160 + value = excluded.value, 161 + updated_at = excluded.updated_at`, 162 + ).run(key, value, now) 163 + } 164 + 165 + // ── channel configuration ────────────────────────────────────────────── 166 + 167 + /** 168 + * Ensures configured channel ids exist in the `discord_channels` table. 169 + * 170 + * @param channelIds - Optional override channel ids. 171 + */ 172 + export function ensureConfiguredChannelsMaterialized(channelIds?: string[] | string | null): void { 173 + const ids = parseChannelIds(channelIds) 174 + if (ids.length === 0) return 175 + 176 + const db = getDb() 177 + const now = new Date().toISOString() 178 + const stmt = db.prepare( 179 + `insert into discord_channels ( 180 + channel_id, configured, first_seen_at, last_seen_at, raw_json 181 + ) values (?, 1, ?, ?, ?) 182 + on conflict(channel_id) do update set 183 + configured = 1, 184 + last_seen_at = excluded.last_seen_at`, 185 + ) 186 + 187 + for (const channelId of ids) { 188 + stmt.run(channelId, now, now, "{}") 189 + } 190 + } 191 + 192 + // ── maintenance ──────────────────────────────────────────────────────── 193 + 194 + /** 195 + * Auto-demotes stale pending inbox items to `"seen"` after a timeout. 196 + * 197 + * @param staleMinutes - Minutes after which pending items are demoted. 198 + * @returns Number of demoted items. 199 + */ 200 + export function autoDemoteStalePendingItems(staleMinutes: number): number { 201 + if (staleMinutes <= 0) return 0 202 + 203 + const db = getDb() 204 + const nowIso = new Date().toISOString() 205 + const cutoffIso = new Date(Date.now() - staleMinutes * 60_000).toISOString() 206 + const note = `auto-demoted after ${staleMinutes}m pending timeout` 207 + 208 + const result = db.prepare( 209 + `update discord_items 210 + set status = 'seen', 211 + action_taken = case when action_taken = 'none' then 'noted' else action_taken end, 212 + decision_note = coalesce(decision_note, ?), 213 + last_decision_at = ?, 214 + last_seen_at = ? 215 + where status = 'pending' 216 + and first_seen_at <= ?`, 217 + ).run(note, nowIso, nowIso, cutoffIso) 218 + 219 + return Number(result.changes ?? 0) 220 + } 221 + 222 + /** 223 + * Repairs DM/guild flags on messages and channels based on cross-referenced data. 224 + * 225 + * @returns Total number of repaired rows. 226 + */ 227 + export function repairDiscordMessageChannelFlags(): number { 228 + const db = getDb() 229 + const now = new Date().toISOString() 230 + 231 + const dmMessageResult = db.prepare( 232 + `update discord_messages 233 + set is_dm = 1, 234 + channel_type = coalesce(channel_type, 1), 235 + last_seen_at = ? 236 + where guild_id is null 237 + and is_dm = 0`, 238 + ).run(now) 239 + 240 + const dmChannelResult = db.prepare( 241 + `update discord_channels 242 + set is_dm = 1, 243 + channel_type = case 244 + when channel_type is null or channel_type = 0 then 1 245 + else channel_type 246 + end, 247 + last_seen_at = ? 248 + where is_dm = 0 249 + and exists ( 250 + select 1 from discord_messages m 251 + where m.channel_id = discord_channels.channel_id 252 + and m.guild_id is null 253 + and m.is_dm = 1 254 + )`, 255 + ).run(now) 256 + 257 + const guildMessageResult = db.prepare( 258 + `update discord_messages 259 + set guild_id = ( 260 + select c.guild_id from discord_channels c 261 + where c.channel_id = discord_messages.channel_id 262 + and c.guild_id is not null 263 + ), 264 + channel_type = coalesce( 265 + channel_type, 266 + (select c.channel_type from discord_channels c where c.channel_id = discord_messages.channel_id) 267 + ), 268 + is_dm = 0, 269 + last_seen_at = ? 270 + where is_dm = 1 271 + and exists ( 272 + select 1 from discord_channels c 273 + where c.channel_id = discord_messages.channel_id 274 + and c.guild_id is not null 275 + )`, 276 + ).run(now) 277 + 278 + return Number(dmMessageResult.changes ?? 0) + Number(dmChannelResult.changes ?? 0) + Number(guildMessageResult.changes ?? 0) 279 + } 280 + 281 + // ── inbox queries ────────────────────────────────────────────────────── 282 + 283 + type InboxStatus = "pending" | "seen" | "acted" | "ignored" 284 + 285 + const VALID_STATUS = new Set<InboxStatus>(["pending", "seen", "acted", "ignored"]) 286 + 287 + /** 288 + * Normalizes a status filter input into a validated array of inbox statuses. 289 + * 290 + * @param input - Comma-separated string or array. 291 + * @returns Array of valid statuses, defaulting to `["pending"]`. 292 + */ 293 + export function normalizeStatuses(input?: string[] | string | null): InboxStatus[] { 294 + const rawValues = Array.isArray(input) 295 + ? input 296 + : typeof input === "string" 297 + ? input.split(",").map((x) => x.trim()) 298 + : ["pending"] 299 + 300 + const values = rawValues 301 + .map((x) => x.trim()) 302 + .filter((x): x is InboxStatus => VALID_STATUS.has(x as InboxStatus)) 303 + 304 + return values.length > 0 ? values : ["pending"] 305 + } 306 + 307 + /** 308 + * Returns whether a channel id is in the configured set. 309 + * 310 + * @param channelId - Channel id to check. 311 + * @returns `true` when the channel is configured for inbox scanning. 312 + */ 313 + export function isConfiguredDiscordChannel(channelId: string): boolean { 314 + return configuredChannelIdSet().has(channelId) 315 + } 316 + 317 + // ── reply context ────────────────────────────────────────────────────── 318 + 319 + export type DiscordReplyContext = { 320 + message_id: string 321 + author_id: string | null 322 + author_username: string | null 323 + content: string 324 + } 325 + 326 + function asObject(value: unknown) { 327 + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null 328 + } 329 + 330 + /** 331 + * Extracts the referenced (replied-to) message id from raw JSON. 332 + * 333 + * @param rawJson - Stored raw JSON for a Discord message. 334 + * @returns Referenced message id, or `null`. 335 + */ 336 + export function extractReferencedMessageId(rawJson: string): string | null { 337 + try { 338 + const root = asObject(JSON.parse(rawJson)) 339 + if (!root) return null 340 + const message = asObject(root.message) ?? root 341 + const reference = 342 + asObject(message.message_reference) ?? 343 + asObject(root.message_reference) ?? 344 + asObject(message.reference) ?? 345 + asObject(root.reference) 346 + 347 + const direct = 348 + asString(reference?.message_id) ?? 349 + asString(reference?.messageId) ?? 350 + asString(reference?.id) 351 + if (direct) return direct 352 + 353 + const referencedMessage = asObject(message.referenced_message) ?? asObject(root.referenced_message) 354 + return asString(referencedMessage?.id) 355 + } catch { 356 + return null 357 + } 358 + } 359 + 360 + /** 361 + * Extracts an embedded referenced message from raw JSON (for messages that include it inline). 362 + * 363 + * @param rawJson - Stored raw JSON for a Discord message. 364 + * @returns Parsed reply context, or `null`. 365 + */ 366 + export function extractEmbeddedReferencedMessage(rawJson: string): DiscordReplyContext | null { 367 + try { 368 + const root = asObject(JSON.parse(rawJson)) 369 + if (!root) return null 370 + const message = asObject(root.message) ?? root 371 + const referenced = asObject(message.referenced_message) ?? asObject(root.referenced_message) 372 + if (!referenced) return null 373 + 374 + const messageId = asString(referenced.id) 375 + if (!messageId) return null 376 + 377 + const author = asObject(referenced.author) 378 + return { 379 + message_id: messageId, 380 + author_id: asString(author?.id), 381 + author_username: asString(author?.username ?? author?.global_name), 382 + content: String(referenced.content ?? ""), 383 + } 384 + } catch { 385 + return null 386 + } 387 + } 388 + 389 + /** 390 + * Builds a map of message id → reply context for a set of messages that may reference others. 391 + * 392 + * @param rows - Messages with raw_json to scan for references. 393 + * @returns Map from message id to the reply target context. 394 + */ 395 + export function buildReplyTargetContextMap(rows: Array<{ message_id: string; raw_json: string }>): Map<string, DiscordReplyContext> { 396 + const refsByMessage = new Map<string, { refId: string; embedded: DiscordReplyContext | null }>() 397 + for (const row of rows) { 398 + const refId = extractReferencedMessageId(row.raw_json) 399 + if (refId) refsByMessage.set(row.message_id, { refId, embedded: extractEmbeddedReferencedMessage(row.raw_json) }) 400 + } 401 + 402 + if (refsByMessage.size === 0) return new Map() 403 + 404 + const refIds = Array.from(new Set([...refsByMessage.values()].map((ref) => ref.refId))) 405 + const db = getDb() 406 + const placeholders = refIds.map(() => "?").join(", ") 407 + const targetRows = db 408 + .prepare( 409 + `select message_id, author_id, author_username, content 410 + from discord_messages 411 + where message_id in (${placeholders})`, 412 + ) 413 + .all(...refIds) as DiscordReplyContext[] 414 + 415 + const contextById = new Map<string, DiscordReplyContext>() 416 + for (const row of targetRows) { 417 + contextById.set(row.message_id, row) 418 + } 419 + 420 + const out = new Map<string, DiscordReplyContext>() 421 + for (const [messageId, ref] of refsByMessage.entries()) { 422 + const fallback = ref.embedded ?? { 423 + message_id: ref.refId, 424 + author_id: null, 425 + author_username: null, 426 + content: "", 427 + } 428 + out.set(messageId, contextById.get(ref.refId) ?? fallback) 429 + } 430 + 431 + return out 432 + }
+2 -2
src/discord/gateway.ts
··· 7 7 Partials, 8 8 type Message, 9 9 } from "discord.js" 10 - import { handleDiscordIngress } from "./pipeline.js" 11 - import { subscribeRunnerPresence, type RunnerPresence } from "../runner/presence.js" 10 + import { handleDiscordIngress } from "./pipeline" 11 + import { subscribeRunnerPresence, type RunnerPresence } from "../runner/presence" 12 12 13 13 function asEnabled(value: string | undefined, fallback: boolean): boolean { 14 14 if (typeof value !== "string") return fallback
+240
src/discord/parse.ts
··· 1 + /** 2 + * Safe type-coercion helpers and Discord payload parsers. 3 + * 4 + * @module discord/parse 5 + */ 6 + 7 + export type DiscordObject = Record<string, unknown> 8 + 9 + export type DiscordMessageRecord = { 10 + messageId: string 11 + channelId: string 12 + guildId: string | null 13 + channelType: number | null 14 + authorId: string | null 15 + authorUsername: string | null 16 + content: string 17 + createdAt: string 18 + isDm: boolean 19 + isFromBot: boolean 20 + isFromSelf: boolean 21 + mentionsBot: boolean 22 + rawJson: string 23 + } 24 + 25 + export type DiscordChannelRecord = { 26 + channelId: string 27 + guildId: string | null 28 + channelType: number | null 29 + channelName: string | null 30 + guildName: string | null 31 + topic: string | null 32 + isDm: boolean 33 + configured: boolean 34 + rawJson: string 35 + } 36 + 37 + /** 38 + * Coerces an unknown value to a plain object, returning `null` for non-objects and arrays. 39 + * 40 + * @param value - Value to coerce. 41 + * @returns Plain object or `null`. 42 + */ 43 + export function asObject(value: unknown): DiscordObject | null { 44 + return value && typeof value === "object" && !Array.isArray(value) ? (value as DiscordObject) : null 45 + } 46 + 47 + /** 48 + * Coerces an unknown value to a non-empty trimmed string. 49 + * 50 + * @param value - Value to coerce. 51 + * @returns Trimmed string or `null`. 52 + */ 53 + export function asString(value: unknown): string | null { 54 + if (typeof value === "string") { 55 + const trimmed = value.trim() 56 + return trimmed.length > 0 ? trimmed : null 57 + } 58 + if (typeof value === "number" && Number.isFinite(value)) return String(value) 59 + return null 60 + } 61 + 62 + /** 63 + * Coerces an unknown value to a boolean. 64 + * 65 + * @param value - Value to coerce. 66 + * @returns Boolean interpretation. 67 + */ 68 + export function asBoolean(value: unknown): boolean { 69 + if (typeof value === "boolean") return value 70 + if (typeof value === "number") return value !== 0 71 + if (typeof value === "string") { 72 + const normalized = value.trim().toLowerCase() 73 + return normalized === "1" || normalized === "true" || normalized === "yes" 74 + } 75 + return false 76 + } 77 + 78 + /** 79 + * Coerces an unknown value to a finite number. 80 + * 81 + * @param value - Value to coerce. 82 + * @returns Finite number or `null`. 83 + */ 84 + export function asNumber(value: unknown): number | null { 85 + if (typeof value === "number" && Number.isFinite(value)) return value 86 + if (typeof value === "string") { 87 + const parsed = Number.parseInt(value, 10) 88 + return Number.isFinite(parsed) ? parsed : null 89 + } 90 + return null 91 + } 92 + 93 + /** 94 + * Coerces an unknown value to an ISO-8601 timestamp string. 95 + * 96 + * @param value - Value to coerce. 97 + * @returns ISO string, falling back to now on failure. 98 + */ 99 + export function toIsoString(value: unknown): string { 100 + const raw = asString(value) 101 + if (!raw) return new Date().toISOString() 102 + const parsed = new Date(raw) 103 + if (Number.isNaN(parsed.getTime())) return new Date().toISOString() 104 + return parsed.toISOString() 105 + } 106 + 107 + /** 108 + * Parses a comma-separated or array channel-id input into a normalized array. 109 + * 110 + * @param input - Channel ids as string, array, or env var. 111 + * @returns Array of trimmed channel id strings. 112 + */ 113 + export function parseChannelIds(input?: string[] | string | null): string[] { 114 + if (Array.isArray(input)) { 115 + return input.map((x) => String(x).trim()).filter(Boolean) 116 + } 117 + 118 + const text = typeof input === "string" ? input : process.env.DISCORD_SCAN_CHANNEL_IDS ?? "" 119 + return text 120 + .split(",") 121 + .map((x) => x.trim()) 122 + .filter(Boolean) 123 + } 124 + 125 + /** 126 + * Returns the set of configured channel ids for inbox detection. 127 + * 128 + * @param input - Optional override channel ids. 129 + * @returns Set of configured channel id strings. 130 + */ 131 + export function configuredChannelIdSet(input?: string[] | string | null): Set<string> { 132 + return new Set(parseChannelIds(input)) 133 + } 134 + 135 + /** 136 + * Parses a raw Discord gateway/webhook payload into a structured message record. 137 + * 138 + * @param payload - Raw event payload. 139 + * @param botUserId - Optional bot user id for self-detection. 140 + * @returns Parsed record, or `null` when the payload lacks identity fields. 141 + */ 142 + export function parseMessageRecord(payload: unknown, botUserId?: string): DiscordMessageRecord | null { 143 + const root = asObject(payload) 144 + if (!root) return null 145 + const botId = botUserId ?? process.env.DISCORD_BOT_USER_ID?.trim() 146 + 147 + const message = asObject(root.message) ?? root 148 + const channel = asObject(root.channel) 149 + const author = asObject(message.author) ?? asObject(root.author) 150 + 151 + const messageId = asString(message.id ?? root.message_id) 152 + const channelId = asString(message.channel_id ?? root.channel_id ?? channel?.id) 153 + 154 + if (!messageId || !channelId) return null 155 + 156 + const guildId = asString(message.guild_id ?? root.guild_id ?? channel?.guild_id) 157 + const channelType = asNumber(message.channel_type ?? root.channel_type ?? channel?.type) 158 + 159 + const authorId = asString(author?.id ?? root.author_id) 160 + const authorUsername = 161 + asString(author?.global_name) ?? asString(author?.username) ?? asString(root.author_username) ?? asString(root.author) 162 + 163 + const content = String(message.content ?? root.content ?? "") 164 + const createdAt = toIsoString(message.timestamp ?? root.timestamp) 165 + 166 + const isDm = 167 + asBoolean(root.is_dm) || 168 + channelType === 1 || 169 + channelType === 3 || 170 + guildId == null 171 + const isFromSelf = Boolean(botId && authorId === botId) 172 + const isFromBot = asBoolean(author?.bot ?? root.author_is_bot) || isFromSelf 173 + 174 + let mentionsBot = asBoolean(root.mentions_bot) 175 + if (!mentionsBot && botId) { 176 + const mentions = Array.isArray(message.mentions) ? message.mentions : [] 177 + mentionsBot = mentions.some((entry) => { 178 + const obj = asObject(entry) 179 + if (!obj) return false 180 + const mentionedId = asString(obj.id) 181 + if (mentionedId && mentionedId === botId) return true 182 + return asBoolean(obj.bot) 183 + }) 184 + 185 + if (!mentionsBot && content.includes(`<@${botId}>`)) mentionsBot = true 186 + if (!mentionsBot && content.includes(`<@!${botId}>`)) mentionsBot = true 187 + } 188 + 189 + return { 190 + messageId, 191 + channelId, 192 + guildId, 193 + channelType, 194 + authorId, 195 + authorUsername, 196 + content, 197 + createdAt, 198 + isDm, 199 + isFromBot, 200 + isFromSelf, 201 + mentionsBot, 202 + rawJson: JSON.stringify(payload), 203 + } 204 + } 205 + 206 + /** 207 + * Parses a raw Discord channel payload into a structured channel record. 208 + * 209 + * @param payload - Raw event payload. 210 + * @param fallback - Optional fallback values for ambiguous payloads. 211 + * @returns Parsed record, or `null` when the payload lacks a channel id. 212 + */ 213 + export function parseChannelRecord( 214 + payload: unknown, 215 + fallback?: { channelId?: string; guildId?: string | null; channelType?: number | null; isDm?: boolean }, 216 + ): DiscordChannelRecord | null { 217 + const root = asObject(payload) 218 + if (!root) return null 219 + 220 + const channel = asObject(root.channel) ?? root 221 + const channelId = asString(channel.id ?? root.channel_id ?? fallback?.channelId) 222 + if (!channelId) return null 223 + 224 + const guildId = asString(channel.guild_id ?? root.guild_id ?? fallback?.guildId) 225 + const channelType = asNumber(channel.type ?? root.channel_type ?? fallback?.channelType) 226 + const isDm = asBoolean(root.is_dm) || channelType === 1 || channelType === 3 || fallback?.isDm === true || guildId == null 227 + const configured = configuredChannelIdSet().has(channelId) 228 + 229 + return { 230 + channelId, 231 + guildId, 232 + channelType, 233 + channelName: asString(channel.name ?? root.channel_name), 234 + guildName: asString(root.guild_name), 235 + topic: asString(channel.topic ?? root.channel_topic), 236 + isDm, 237 + configured, 238 + rawJson: JSON.stringify(channel), 239 + } 240 + }
+3 -3
src/discord/pipeline.ts
··· 1 - import { enqueueEvent, isRunning, wake } from "../runner/index.js" 2 - import { fromDiscord } from "../triggers/discord.js" 3 - import { ingestDiscordEvent } from "./state.js" 1 + import { enqueueEvent, isRunning, wake } from "../runner/index" 2 + import { fromDiscord } from "../triggers/discord" 3 + import { ingestDiscordEvent } from "./state" 4 4 5 5 const DISCORD_WAKE_ON_EVENT = (process.env.DISCORD_WAKE_ON_EVENT ?? "false").trim().toLowerCase() === "true" 6 6
+154
src/discord/rest.ts
··· 1 + /** 2 + * Discord REST client with retry logic. 3 + * 4 + * @module discord/rest 5 + */ 6 + 7 + import { REST, Routes } from "discord.js" 8 + 9 + /** 10 + * Returns the configured Discord bot token. 11 + * 12 + * @returns Bot token string. 13 + * @throws When `DISCORD_BOT_TOKEN` is not set. 14 + */ 15 + export function getBotToken(): string { 16 + const token = process.env.DISCORD_BOT_TOKEN?.trim() 17 + if (!token) throw new Error("DISCORD_BOT_TOKEN is required") 18 + return token 19 + } 20 + 21 + /** 22 + * Creates an authenticated Discord REST client. 23 + * 24 + * @returns Configured REST instance. 25 + */ 26 + export function makeRestClient(): REST { 27 + return new REST({ version: "10" }).setToken(getBotToken()) 28 + } 29 + 30 + /** 31 + * Extracts a machine-readable error code from a thrown value. 32 + * 33 + * @param err - Unknown thrown value. 34 + * @returns Error code string, or empty string if none found. 35 + */ 36 + export function errorCode(err: unknown): string { 37 + const value = err as { code?: unknown; cause?: unknown } 38 + if (typeof value?.code === "string") return value.code 39 + const cause = value?.cause as { code?: unknown } | undefined 40 + return typeof cause?.code === "string" ? cause.code : "" 41 + } 42 + 43 + /** 44 + * Extracts an HTTP status code from a thrown value. 45 + * 46 + * @param err - Unknown thrown value. 47 + * @returns Numeric status, or `null`. 48 + */ 49 + export function errorStatus(err: unknown): number | null { 50 + const value = err as { status?: unknown } 51 + return typeof value?.status === "number" && Number.isFinite(value.status) ? value.status : null 52 + } 53 + 54 + /** 55 + * Extracts a human-readable message from a thrown value. 56 + * 57 + * @param err - Unknown thrown value. 58 + * @returns Error message string. 59 + */ 60 + export function errorMessage(err: unknown): string { 61 + return err instanceof Error ? err.message : String(err) 62 + } 63 + 64 + /** 65 + * Determines whether a Discord REST error is transient and worth retrying. 66 + * 67 + * @param err - Unknown thrown value. 68 + * @returns `true` for rate-limits, gateway errors, and network timeouts. 69 + */ 70 + export function isRetryableDiscordRestError(err: unknown): boolean { 71 + const code = errorCode(err) 72 + if ( 73 + code === "UND_ERR_CONNECT_TIMEOUT" || 74 + code === "UND_ERR_HEADERS_TIMEOUT" || 75 + code === "UND_ERR_SOCKET" || 76 + code === "ECONNRESET" || 77 + code === "ETIMEDOUT" || 78 + code === "ENOTFOUND" || 79 + code === "EAI_AGAIN" || 80 + code === "ENETUNREACH" || 81 + code === "ECONNREFUSED" 82 + ) { 83 + return true 84 + } 85 + 86 + const status = errorStatus(err) 87 + return status === 429 || status === 502 || status === 503 || status === 504 88 + } 89 + 90 + const DISCORD_REST_MAX_ATTEMPTS = Math.max( 91 + 1, 92 + Math.min(10, Number.parseInt(process.env.DISCORD_REST_MAX_ATTEMPTS ?? "3", 10) || 3), 93 + ) 94 + const DISCORD_REST_RETRY_BASE_MS = Math.max( 95 + 100, 96 + Math.min(30_000, Number.parseInt(process.env.DISCORD_REST_RETRY_BASE_MS ?? "1000", 10) || 1000), 97 + ) 98 + 99 + /** 100 + * Computes exponential backoff delay for a retry attempt. 101 + * 102 + * @param attempt - Zero-indexed attempt number. 103 + * @returns Delay in milliseconds. 104 + */ 105 + export function retryDelayMs(attempt: number): number { 106 + return DISCORD_REST_RETRY_BASE_MS * 2 ** attempt 107 + } 108 + 109 + function sleep(ms: number): Promise<void> { 110 + return new Promise((resolve) => setTimeout(resolve, ms)) 111 + } 112 + 113 + /** 114 + * Executes a Discord REST operation with automatic retry on transient errors. 115 + * 116 + * @param label - Human-readable label for log messages. 117 + * @param fn - Async function producing the REST result. 118 + * @returns Result of `fn`. 119 + * @throws The last error when all attempts fail. 120 + */ 121 + export async function withDiscordRestRetry<T>(label: string, fn: () => Promise<T>): Promise<T> { 122 + let lastErr: unknown 123 + 124 + for (let attempt = 0; attempt < DISCORD_REST_MAX_ATTEMPTS; attempt += 1) { 125 + try { 126 + return await fn() 127 + } catch (err) { 128 + lastErr = err 129 + if (!isRetryableDiscordRestError(err) || attempt + 1 >= DISCORD_REST_MAX_ATTEMPTS) break 130 + 131 + const delayMs = retryDelayMs(attempt) 132 + console.warn( 133 + `[discord rest] ${label} failed (${errorCode(err) || errorStatus(err) || "unknown"}: ${errorMessage(err)}); retrying in ${delayMs}ms`, 134 + ) 135 + await sleep(delayMs) 136 + } 137 + } 138 + 139 + throw lastErr 140 + } 141 + 142 + /** 143 + * Resolves the bot's own user id via the REST API. 144 + * 145 + * @param rest - Authenticated REST client. 146 + * @returns Bot user id string. 147 + * @throws When the API returns an unparseable response. 148 + */ 149 + export async function getBotUserId(rest: REST): Promise<string> { 150 + const me = (await withDiscordRestRetry("get current user", () => rest.get(Routes.user("@me")))) as { id?: unknown } 151 + const id = (await import("./parse")).asString(me?.id) 152 + if (!id) throw new Error("failed to resolve bot user id") 153 + return id 154 + }
+266 -735
src/discord/state.ts
··· 1 - import { REST, Routes } from "discord.js" 2 - import { getDb } from "../db.js" 1 + /** 2 + * Discord inbox orchestration — ingest, batch digest, scanning, sending, and listing. 3 + * 4 + * Public API remains identical; internal parsing, REST, and DB operations 5 + * are delegated to their respective modules. 6 + * 7 + * @module discord/state 8 + */ 3 9 4 - type InboxStatus = "pending" | "seen" | "acted" | "ignored" 5 - type InboxAction = "none" | "replied" | "messaged" | "dismissed" | "noted" 6 - type ReplyMode = "auto" | "plain" | "explicit" 7 - 8 - type DiscordObject = Record<string, unknown> 9 - 10 - type DiscordMessageRecord = { 11 - messageId: string 12 - channelId: string 13 - guildId: string | null 14 - channelType: number | null 15 - authorId: string | null 16 - authorUsername: string | null 17 - content: string 18 - createdAt: string 19 - isDm: boolean 20 - isFromBot: boolean 21 - isFromSelf: boolean 22 - mentionsBot: boolean 23 - rawJson: string 24 - } 10 + import { Routes } from "discord.js" 11 + import { getDb } from "../db" 12 + import { 13 + asNumber, 14 + asObject, 15 + asString, 16 + configuredChannelIdSet, 17 + parseChannelIds, 18 + parseChannelRecord, 19 + parseMessageRecord, 20 + type DiscordObject, 21 + } from "./parse" 22 + import { 23 + autoDemoteStalePendingItems, 24 + buildReplyTargetContextMap, 25 + ensureConfiguredChannelsMaterialized, 26 + getDiscordMeta, 27 + isConfiguredDiscordChannel, 28 + normalizeStatuses, 29 + setDiscordMeta, 30 + upsertDiscordChannel, 31 + upsertInboxItem, 32 + upsertDiscordMessage, 33 + repairDiscordMessageChannelFlags, 34 + type DiscordReplyContext, 35 + } from "./db" 36 + import { 37 + errorMessage, 38 + getBotUserId, 39 + makeRestClient, 40 + withDiscordRestRetry, 41 + } from "./rest" 25 42 26 - type DiscordChannelRecord = { 27 - channelId: string 28 - guildId: string | null 29 - channelType: number | null 30 - channelName: string | null 31 - guildName: string | null 32 - topic: string | null 33 - isDm: boolean 34 - configured: boolean 35 - rawJson: string 36 - } 43 + // ── result types ─────────────────────────────────────────────────────── 37 44 38 45 export type DiscordIngestResult = { 39 46 stored: boolean ··· 54 61 to: string 55 62 } 56 63 57 - const DEFAULT_SCAN_LIMIT = 50 58 - const AUTO_REPLY_STALE_MINUTES = 10 59 - const DISCORD_REST_MAX_ATTEMPTS = Math.max( 60 - 1, 61 - Math.min(10, Number.parseInt(process.env.DISCORD_REST_MAX_ATTEMPTS ?? "3", 10) || 3), 62 - ) 63 - const DISCORD_REST_RETRY_BASE_MS = Math.max( 64 - 100, 65 - Math.min(30_000, Number.parseInt(process.env.DISCORD_REST_RETRY_BASE_MS ?? "1000", 10) || 1000), 66 - ) 67 - 68 - const VALID_STATUS = new Set<InboxStatus>(["pending", "seen", "acted", "ignored"]) 69 - const VALID_ACTION = new Set<InboxAction>(["none", "replied", "messaged", "dismissed", "noted"]) 64 + // ── formatting helpers ───────────────────────────────────────────────── 70 65 71 - function asObject(value: unknown): DiscordObject | null { 72 - return value && typeof value === "object" && !Array.isArray(value) ? (value as DiscordObject) : null 66 + function compactText(value: unknown, maxChars = 180): string { 67 + const text = String(value ?? "").replace(/\s+/g, " ").trim() 68 + if (!text) return "(no text)" 69 + if (text.length <= maxChars) return text 70 + return `${text.slice(0, maxChars - 1)}...` 73 71 } 74 72 75 - function asString(value: unknown): string | null { 76 - if (typeof value === "string") { 77 - const trimmed = value.trim() 78 - return trimmed.length > 0 ? trimmed : null 79 - } 80 - if (typeof value === "number" && Number.isFinite(value)) return String(value) 81 - return null 73 + function fullMessageText(value: unknown): string { 74 + const text = String(value ?? "").replace(/\r\n/g, "\n").trim() 75 + if (!text) return "(no text)" 76 + return text.replace(/\n/g, "\n ") 82 77 } 83 78 84 - function asBoolean(value: unknown): boolean { 85 - if (typeof value === "boolean") return value 86 - if (typeof value === "number") return value !== 0 87 - if (typeof value === "string") { 88 - const normalized = value.trim().toLowerCase() 89 - return normalized === "1" || normalized === "true" || normalized === "yes" 90 - } 91 - return false 79 + function formatHumanTimestamp(value: string | null | undefined): string { 80 + if (!value) return "unknown time" 81 + const parsed = new Date(value) 82 + if (Number.isNaN(parsed.getTime())) return value 83 + return parsed.toLocaleString("en-US", { 84 + month: "long", 85 + day: "numeric", 86 + hour: "numeric", 87 + minute: "2-digit", 88 + hour12: true, 89 + timeZoneName: "short", 90 + }) 92 91 } 93 92 94 - function asNumber(value: unknown): number | null { 95 - if (typeof value === "number" && Number.isFinite(value)) return value 96 - if (typeof value === "string") { 97 - const parsed = Number.parseInt(value, 10) 98 - return Number.isFinite(parsed) ? parsed : null 99 - } 100 - return null 93 + function formatBatchTimestamp(value: string | null | undefined): string { 94 + if (!value) return "unknown-time" 95 + const parsed = new Date(value) 96 + if (Number.isNaN(parsed.getTime())) return value 97 + return parsed.toISOString().replace("T", " ").replace(".000Z", "Z") 101 98 } 102 99 103 - function toIsoString(value: unknown): string { 104 - const raw = asString(value) 105 - if (!raw) return new Date().toISOString() 106 - const parsed = new Date(raw) 107 - if (Number.isNaN(parsed.getTime())) return new Date().toISOString() 108 - return parsed.toISOString() 100 + function formatReplyContext(reply: DiscordReplyContext | undefined): string { 101 + if (!reply) return "" 102 + const author = reply.author_username ? `@${reply.author_username}` : "unknown" 103 + return ` [reply_to ${author} msg/${reply.message_id}: ${JSON.stringify(reply.content)}]` 109 104 } 110 105 111 - function parseChannelIds(input?: string[] | string | null): string[] { 112 - if (Array.isArray(input)) { 113 - return input.map((x) => String(x).trim()).filter(Boolean) 114 - } 115 - 116 - const text = typeof input === "string" ? input : process.env.DISCORD_SCAN_CHANNEL_IDS ?? "" 117 - return text 118 - .split(",") 119 - .map((x) => x.trim()) 120 - .filter(Boolean) 106 + function channelLabel(row: { 107 + is_dm: number 108 + guild_name: string | null 109 + guild_id: string | null 110 + channel_name: string | null 111 + channel_id: string 112 + }): string { 113 + if (row.is_dm === 1) return `dm/${row.channel_id}` 114 + const guild = row.guild_name ?? row.guild_id ?? "unknown-guild" 115 + const channel = row.channel_name ?? row.channel_id 116 + return `channel/${guild}/#${channel}` 121 117 } 122 118 123 - function configuredChannelIdSet(input?: string[] | string | null): Set<string> { 124 - return new Set(parseChannelIds(input)) 125 - } 119 + // ── inbox detection ──────────────────────────────────────────────────── 126 120 127 - function getBotToken(): string { 128 - const token = process.env.DISCORD_BOT_TOKEN?.trim() 129 - if (!token) throw new Error("DISCORD_BOT_TOKEN is required") 130 - return token 131 - } 132 - 133 - function makeRestClient(): REST { 134 - return new REST({ version: "10" }).setToken(getBotToken()) 135 - } 136 - 137 - function errorCode(err: unknown): string { 138 - const value = err as { code?: unknown; cause?: unknown } 139 - if (typeof value?.code === "string") return value.code 140 - const cause = value?.cause as { code?: unknown } | undefined 141 - return typeof cause?.code === "string" ? cause.code : "" 142 - } 143 - 144 - function errorStatus(err: unknown): number | null { 145 - const value = err as { status?: unknown } 146 - return typeof value?.status === "number" && Number.isFinite(value.status) ? value.status : null 147 - } 148 - 149 - function errorMessage(err: unknown): string { 150 - return err instanceof Error ? err.message : String(err) 151 - } 152 - 153 - function isRetryableDiscordRestError(err: unknown): boolean { 154 - const code = errorCode(err) 155 - if ( 156 - code === "UND_ERR_CONNECT_TIMEOUT" || 157 - code === "UND_ERR_HEADERS_TIMEOUT" || 158 - code === "UND_ERR_SOCKET" || 159 - code === "ECONNRESET" || 160 - code === "ETIMEDOUT" || 161 - code === "ENOTFOUND" || 162 - code === "EAI_AGAIN" || 163 - code === "ENETUNREACH" || 164 - code === "ECONNREFUSED" 165 - ) { 166 - return true 167 - } 168 - 169 - const status = errorStatus(err) 170 - return status === 429 || status === 502 || status === 503 || status === 504 171 - } 172 - 173 - function retryDelayMs(attempt: number): number { 174 - return DISCORD_REST_RETRY_BASE_MS * 2 ** attempt 175 - } 176 - 177 - function sleep(ms: number): Promise<void> { 178 - return new Promise((resolve) => setTimeout(resolve, ms)) 179 - } 180 - 181 - async function withDiscordRestRetry<T>(label: string, fn: () => Promise<T>): Promise<T> { 182 - let lastErr: unknown 183 - 184 - for (let attempt = 0; attempt < DISCORD_REST_MAX_ATTEMPTS; attempt += 1) { 185 - try { 186 - return await fn() 187 - } catch (err) { 188 - lastErr = err 189 - if (!isRetryableDiscordRestError(err) || attempt + 1 >= DISCORD_REST_MAX_ATTEMPTS) break 190 - 191 - const delayMs = retryDelayMs(attempt) 192 - console.warn( 193 - `[discord rest] ${label} failed (${errorCode(err) || errorStatus(err) || "unknown"}: ${errorMessage(err)}); retrying in ${delayMs}ms`, 194 - ) 195 - await sleep(delayMs) 196 - } 197 - } 198 - 199 - throw lastErr 200 - } 201 - 202 - async function getBotUserId(rest: REST): Promise<string> { 203 - const me = (await withDiscordRestRetry("get current user", () => rest.get(Routes.user("@me")))) as { id?: unknown } 204 - const id = asString(me?.id) 205 - if (!id) throw new Error("failed to resolve bot user id") 206 - return id 207 - } 208 - 209 - function parseMessageRecord(payload: unknown, botUserId?: string): DiscordMessageRecord | null { 210 - const root = asObject(payload) 211 - if (!root) return null 212 - const botId = botUserId ?? process.env.DISCORD_BOT_USER_ID?.trim() 213 - 214 - const message = asObject(root.message) ?? root 215 - const channel = asObject(root.channel) 216 - const author = asObject(message.author) ?? asObject(root.author) 217 - 218 - const messageId = asString(message.id ?? root.message_id) 219 - const channelId = asString(message.channel_id ?? root.channel_id ?? channel?.id) 220 - 221 - if (!messageId || !channelId) return null 222 - 223 - const guildId = asString(message.guild_id ?? root.guild_id ?? channel?.guild_id) 224 - const channelType = asNumber(message.channel_type ?? root.channel_type ?? channel?.type) 225 - 226 - const authorId = asString(author?.id ?? root.author_id) 227 - const authorUsername = 228 - asString(author?.global_name) ?? asString(author?.username) ?? asString(root.author_username) ?? asString(root.author) 229 - 230 - const content = String(message.content ?? root.content ?? "") 231 - const createdAt = toIsoString(message.timestamp ?? root.timestamp) 232 - 233 - const isDm = 234 - asBoolean(root.is_dm) || 235 - channelType === 1 || 236 - channelType === 3 || 237 - guildId == null 238 - const isFromSelf = Boolean(botId && authorId === botId) 239 - const isFromBot = asBoolean(author?.bot ?? root.author_is_bot) || isFromSelf 240 - 241 - let mentionsBot = asBoolean(root.mentions_bot) 242 - if (!mentionsBot && botId) { 243 - const mentions = Array.isArray(message.mentions) ? message.mentions : [] 244 - mentionsBot = mentions.some((entry) => { 245 - const obj = asObject(entry) 246 - if (!obj) return false 247 - const mentionedId = asString(obj.id) 248 - if (mentionedId && mentionedId === botId) return true 249 - return asBoolean(obj.bot) 250 - }) 251 - 252 - if (!mentionsBot && content.includes(`<@${botId}>`)) mentionsBot = true 253 - if (!mentionsBot && content.includes(`<@!${botId}>`)) mentionsBot = true 254 - } 255 - 256 - return { 257 - messageId, 258 - channelId, 259 - guildId, 260 - channelType, 261 - authorId, 262 - authorUsername, 263 - content, 264 - createdAt, 265 - isDm, 266 - isFromBot, 267 - isFromSelf, 268 - mentionsBot, 269 - rawJson: JSON.stringify(payload), 270 - } 271 - } 272 - 273 - function parseChannelRecord( 274 - payload: unknown, 275 - fallback?: { channelId?: string; guildId?: string | null; channelType?: number | null; isDm?: boolean }, 276 - ): DiscordChannelRecord | null { 277 - const root = asObject(payload) 278 - if (!root) return null 279 - 280 - const channel = asObject(root.channel) ?? root 281 - const channelId = asString(channel.id ?? root.channel_id ?? fallback?.channelId) 282 - if (!channelId) return null 283 - 284 - const guildId = asString(channel.guild_id ?? root.guild_id ?? fallback?.guildId) 285 - const channelType = asNumber(channel.type ?? root.channel_type ?? fallback?.channelType) 286 - const isDm = asBoolean(root.is_dm) || channelType === 1 || channelType === 3 || fallback?.isDm === true || guildId == null 287 - const configured = configuredChannelIdSet().has(channelId) 288 - 289 - return { 290 - channelId, 291 - guildId, 292 - channelType, 293 - channelName: asString(channel.name ?? root.channel_name), 294 - guildName: asString(root.guild_name), 295 - topic: asString(channel.topic ?? root.channel_topic), 296 - isDm, 297 - configured, 298 - rawJson: JSON.stringify(channel), 299 - } 300 - } 301 - 302 - function upsertDiscordChannel(record: DiscordChannelRecord): void { 303 - const db = getDb() 304 - const now = new Date().toISOString() 305 - 306 - db.prepare( 307 - `insert into discord_channels ( 308 - channel_id, guild_id, channel_type, channel_name, guild_name, topic, 309 - is_dm, configured, first_seen_at, last_seen_at, raw_json 310 - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 311 - on conflict(channel_id) do update set 312 - guild_id = coalesce(excluded.guild_id, discord_channels.guild_id), 313 - channel_type = coalesce(excluded.channel_type, discord_channels.channel_type), 314 - channel_name = coalesce(excluded.channel_name, discord_channels.channel_name), 315 - guild_name = coalesce(excluded.guild_name, discord_channels.guild_name), 316 - topic = coalesce(excluded.topic, discord_channels.topic), 317 - is_dm = excluded.is_dm, 318 - configured = max(discord_channels.configured, excluded.configured), 319 - last_seen_at = excluded.last_seen_at, 320 - raw_json = excluded.raw_json`, 321 - ).run( 322 - record.channelId, 323 - record.guildId, 324 - record.channelType, 325 - record.channelName, 326 - record.guildName, 327 - record.topic, 328 - record.isDm ? 1 : 0, 329 - record.configured ? 1 : 0, 330 - now, 331 - now, 332 - record.rawJson, 333 - ) 334 - } 335 - 336 - function upsertDiscordMessage(record: DiscordMessageRecord): void { 337 - const db = getDb() 338 - const now = new Date().toISOString() 339 - 340 - db.prepare( 341 - `insert into discord_messages ( 342 - message_id, channel_id, guild_id, channel_type, 343 - author_id, author_username, content, created_at, 344 - is_dm, mentions_bot, is_from_bot, 345 - first_seen_at, last_seen_at, raw_json 346 - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 347 - on conflict(message_id) do update set 348 - channel_id = excluded.channel_id, 349 - guild_id = coalesce(excluded.guild_id, discord_messages.guild_id), 350 - channel_type = coalesce(excluded.channel_type, discord_messages.channel_type), 351 - author_id = excluded.author_id, 352 - author_username = excluded.author_username, 353 - content = excluded.content, 354 - created_at = excluded.created_at, 355 - is_dm = case 356 - when excluded.guild_id is not null then 0 357 - when discord_messages.guild_id is not null and excluded.guild_id is null then discord_messages.is_dm 358 - else excluded.is_dm 359 - end, 360 - mentions_bot = excluded.mentions_bot, 361 - is_from_bot = excluded.is_from_bot, 362 - last_seen_at = excluded.last_seen_at, 363 - raw_json = excluded.raw_json`, 364 - ).run( 365 - record.messageId, 366 - record.channelId, 367 - record.guildId, 368 - record.channelType, 369 - record.authorId, 370 - record.authorUsername, 371 - record.content, 372 - record.createdAt, 373 - record.isDm ? 1 : 0, 374 - record.mentionsBot ? 1 : 0, 375 - record.isFromBot ? 1 : 0, 376 - now, 377 - now, 378 - record.rawJson, 379 - ) 380 - } 381 - 382 - function upsertInboxItem(messageId: string, bucket: "dm" | "mention"): void { 383 - const db = getDb() 384 - const now = new Date().toISOString() 385 - 386 - db.prepare( 387 - `insert into discord_items ( 388 - item_id, message_id, bucket, status, 389 - action_taken, first_seen_at, last_seen_at 390 - ) values (?, ?, ?, 'pending', 'none', ?, ?) 391 - on conflict(item_id) do update set 392 - bucket = excluded.bucket, 393 - last_seen_at = excluded.last_seen_at`, 394 - ).run(messageId, messageId, bucket, now, now) 395 - } 396 - 397 - function isConfiguredDiscordChannel(channelId: string): boolean { 398 - return configuredChannelIdSet().has(channelId) 399 - } 400 - 401 - function detectInboxBucket(record: DiscordMessageRecord): "dm" | "mention" | null { 121 + function detectInboxBucket(record: ReturnType<typeof parseMessageRecord>): "dm" | "mention" | null { 122 + if (!record) return null 402 123 if (record.isFromSelf) return null 403 124 if (record.isDm) return "dm" 404 125 if (record.mentionsBot && isConfiguredDiscordChannel(record.channelId)) return "mention" 405 126 return null 406 127 } 407 128 129 + // ── ingest ───────────────────────────────────────────────────────────── 130 + 131 + /** 132 + * Parses and persists a Discord event into the local message/channel/inbox tables. 133 + * 134 + * @param payload - Raw Discord gateway or webhook payload. 135 + * @param options - Optional bot user id override. 136 + * @returns Ingest result describing what was stored and classified. 137 + */ 408 138 export function ingestDiscordEvent(payload: unknown, options?: { botUserId?: string }): DiscordIngestResult { 409 139 const record = parseMessageRecord(payload, options?.botUserId) 410 140 if (!record) { ··· 453 183 } 454 184 } 455 185 456 - function ensureConfiguredChannelsMaterialized(channelIds?: string[] | string | null): void { 457 - const ids = parseChannelIds(channelIds) 458 - if (ids.length === 0) return 459 - 460 - const db = getDb() 461 - const now = new Date().toISOString() 462 - const stmt = db.prepare( 463 - `insert into discord_channels ( 464 - channel_id, configured, first_seen_at, last_seen_at, raw_json 465 - ) values (?, 1, ?, ?, ?) 466 - on conflict(channel_id) do update set 467 - configured = 1, 468 - last_seen_at = excluded.last_seen_at`, 469 - ) 186 + // ── inbox listing ────────────────────────────────────────────────────── 470 187 471 - for (const channelId of ids) { 472 - stmt.run(channelId, now, now, "{}") 473 - } 474 - } 475 - 476 - function getDiscordMeta(key: string): string | null { 477 - const db = getDb() 478 - const row = db 479 - .prepare(`select value from discord_meta where key = ?`) 480 - .get(key) as { value?: string } | undefined 481 - return row?.value ?? null 482 - } 483 - 484 - function setDiscordMeta(key: string, value: string): void { 485 - const db = getDb() 486 - const now = new Date().toISOString() 487 - db.prepare( 488 - `insert into discord_meta (key, value, updated_at) 489 - values (?, ?, ?) 490 - on conflict(key) do update set 491 - value = excluded.value, 492 - updated_at = excluded.updated_at`, 493 - ).run(key, value, now) 494 - } 495 - 496 - function compactText(value: unknown, maxChars = 180): string { 497 - const text = String(value ?? "").replace(/\s+/g, " ").trim() 498 - if (!text) return "(no text)" 499 - if (text.length <= maxChars) return text 500 - return `${text.slice(0, maxChars - 1)}...` 501 - } 502 - 503 - function fullMessageText(value: unknown): string { 504 - const text = String(value ?? "").replace(/\r\n/g, "\n").trim() 505 - if (!text) return "(no text)" 506 - return text.replace(/\n/g, "\n ") 507 - } 508 - 509 - function formatHumanTimestamp(value: string | null | undefined): string { 510 - if (!value) return "unknown time" 511 - const parsed = new Date(value) 512 - if (Number.isNaN(parsed.getTime())) return value 513 - return parsed.toLocaleString("en-US", { 514 - month: "long", 515 - day: "numeric", 516 - hour: "numeric", 517 - minute: "2-digit", 518 - hour12: true, 519 - timeZoneName: "short", 520 - }) 521 - } 522 - 523 - function formatBatchTimestamp(value: string | null | undefined): string { 524 - if (!value) return "unknown-time" 525 - const parsed = new Date(value) 526 - if (Number.isNaN(parsed.getTime())) return value 527 - return parsed.toISOString().replace("T", " ").replace(".000Z", "Z") 528 - } 529 - 530 - function extractReferencedMessageId(rawJson: string): string | null { 531 - try { 532 - const root = asObject(JSON.parse(rawJson)) 533 - if (!root) return null 534 - const message = asObject(root.message) ?? root 535 - const reference = 536 - asObject(message.message_reference) ?? 537 - asObject(root.message_reference) ?? 538 - asObject(message.reference) ?? 539 - asObject(root.reference) 540 - 541 - const direct = 542 - asString(reference?.message_id) ?? 543 - asString(reference?.messageId) ?? 544 - asString(reference?.id) 545 - if (direct) return direct 546 - 547 - const referencedMessage = asObject(message.referenced_message) ?? asObject(root.referenced_message) 548 - return asString(referencedMessage?.id) 549 - } catch { 550 - return null 551 - } 552 - } 553 - 554 - type DiscordReplyContext = { 555 - message_id: string 556 - author_id: string | null 557 - author_username: string | null 558 - content: string 559 - } 560 - 561 - function extractEmbeddedReferencedMessage(rawJson: string): DiscordReplyContext | null { 562 - try { 563 - const root = asObject(JSON.parse(rawJson)) 564 - if (!root) return null 565 - const message = asObject(root.message) ?? root 566 - const referenced = asObject(message.referenced_message) ?? asObject(root.referenced_message) 567 - if (!referenced) return null 568 - 569 - const messageId = asString(referenced.id) 570 - if (!messageId) return null 571 - 572 - const author = asObject(referenced.author) 573 - return { 574 - message_id: messageId, 575 - author_id: asString(author?.id), 576 - author_username: asString(author?.username ?? author?.global_name), 577 - content: String(referenced.content ?? ""), 578 - } 579 - } catch { 580 - return null 581 - } 582 - } 583 - 584 - function buildReplyTargetContextMap(rows: Array<{ message_id: string; raw_json: string }>): Map<string, DiscordReplyContext> { 585 - const refsByMessage = new Map<string, { refId: string; embedded: DiscordReplyContext | null }>() 586 - for (const row of rows) { 587 - const refId = extractReferencedMessageId(row.raw_json) 588 - if (refId) refsByMessage.set(row.message_id, { refId, embedded: extractEmbeddedReferencedMessage(row.raw_json) }) 589 - } 590 - 591 - if (refsByMessage.size === 0) return new Map() 592 - 593 - const refIds = Array.from(new Set([...refsByMessage.values()].map((ref) => ref.refId))) 594 - const db = getDb() 595 - const placeholders = refIds.map(() => "?").join(", ") 596 - const targetRows = db 597 - .prepare( 598 - `select message_id, author_id, author_username, content 599 - from discord_messages 600 - where message_id in (${placeholders})`, 601 - ) 602 - .all(...refIds) as DiscordReplyContext[] 603 - 604 - const contextById = new Map<string, DiscordReplyContext>() 605 - for (const row of targetRows) { 606 - contextById.set(row.message_id, row) 607 - } 608 - 609 - const out = new Map<string, DiscordReplyContext>() 610 - for (const [messageId, ref] of refsByMessage.entries()) { 611 - const fallback = ref.embedded ?? { 612 - message_id: ref.refId, 613 - author_id: null, 614 - author_username: null, 615 - content: "", 616 - } 617 - out.set(messageId, contextById.get(ref.refId) ?? fallback) 618 - } 619 - 620 - return out 621 - } 622 - 623 - function formatReplyContext(reply: DiscordReplyContext | undefined): string { 624 - if (!reply) return "" 625 - const author = reply.author_username ? `@${reply.author_username}` : "unknown" 626 - return ` [reply_to ${author} msg/${reply.message_id}: ${JSON.stringify(reply.content)}]` 627 - } 628 - 629 - function autoDemoteStalePendingItems(staleMinutes: number): number { 630 - if (staleMinutes <= 0) return 0 631 - 632 - const db = getDb() 633 - const nowIso = new Date().toISOString() 634 - const cutoffIso = new Date(Date.now() - staleMinutes * 60_000).toISOString() 635 - const note = `auto-demoted after ${staleMinutes}m pending timeout` 636 - 637 - const result = db.prepare( 638 - `update discord_items 639 - set status = 'seen', 640 - action_taken = case when action_taken = 'none' then 'noted' else action_taken end, 641 - decision_note = coalesce(decision_note, ?), 642 - last_decision_at = ?, 643 - last_seen_at = ? 644 - where status = 'pending' 645 - and first_seen_at <= ?`, 646 - ).run(note, nowIso, nowIso, cutoffIso) 647 - 648 - return Number(result.changes ?? 0) 649 - } 650 - 651 - function repairDiscordMessageChannelFlags(): number { 652 - const db = getDb() 653 - const dmMessageResult = db.prepare( 654 - `update discord_messages 655 - set is_dm = 1, 656 - channel_type = coalesce(channel_type, 1), 657 - last_seen_at = ? 658 - where guild_id is null 659 - and is_dm = 0`, 660 - ).run(new Date().toISOString()) 661 - 662 - const dmChannelResult = db.prepare( 663 - `update discord_channels 664 - set is_dm = 1, 665 - channel_type = case 666 - when channel_type is null or channel_type = 0 then 1 667 - else channel_type 668 - end, 669 - last_seen_at = ? 670 - where is_dm = 0 671 - and exists ( 672 - select 1 from discord_messages m 673 - where m.channel_id = discord_channels.channel_id 674 - and m.guild_id is null 675 - and m.is_dm = 1 676 - )`, 677 - ).run(new Date().toISOString()) 678 - 679 - const guildMessageResult = db.prepare( 680 - `update discord_messages 681 - set guild_id = ( 682 - select c.guild_id from discord_channels c 683 - where c.channel_id = discord_messages.channel_id 684 - and c.guild_id is not null 685 - ), 686 - channel_type = coalesce( 687 - channel_type, 688 - (select c.channel_type from discord_channels c where c.channel_id = discord_messages.channel_id) 689 - ), 690 - is_dm = 0, 691 - last_seen_at = ? 692 - where is_dm = 1 693 - and exists ( 694 - select 1 from discord_channels c 695 - where c.channel_id = discord_messages.channel_id 696 - and c.guild_id is not null 697 - )`, 698 - ).run(new Date().toISOString()) 699 - 700 - return Number(dmMessageResult.changes ?? 0) + Number(dmChannelResult.changes ?? 0) + Number(guildMessageResult.changes ?? 0) 701 - } 702 - 703 - function channelLabel(row: { 704 - is_dm: number 705 - guild_name: string | null 706 - guild_id: string | null 707 - channel_name: string | null 708 - channel_id: string 709 - }): string { 710 - if (row.is_dm === 1) return `dm/${row.channel_id}` 711 - const guild = row.guild_name ?? row.guild_id ?? "unknown-guild" 712 - const channel = row.channel_name ?? row.channel_id 713 - return `channel/${guild}/#${channel}` 714 - } 715 - 716 - function normalizeStatuses(input?: string[] | string | null): InboxStatus[] { 717 - const rawValues = Array.isArray(input) 718 - ? input 719 - : typeof input === "string" 720 - ? input.split(",").map((x) => x.trim()) 721 - : ["pending"] 722 - 723 - const values = rawValues 724 - .map((x) => x.trim()) 725 - .filter((x): x is InboxStatus => VALID_STATUS.has(x as InboxStatus)) 726 - 727 - return values.length > 0 ? values : ["pending"] 728 - } 729 - 188 + /** 189 + * Lists Discord inbox items from local state. 190 + * 191 + * @param limit - Maximum rows (default 20, max 200). 192 + * @param statuses - Status filter string or array. 193 + * @returns Raw inbox rows. 194 + */ 730 195 export function listDiscordInbox(limit = 20, statuses?: string[] | string): unknown[] { 731 196 const db = getDb() 732 197 const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit) || 20)) ··· 761 226 return stmt.all(...statusList, safeLimit) 762 227 } 763 228 229 + // ── backread ─────────────────────────────────────────────────────────── 230 + 231 + /** 232 + * Reads stored Discord message history for a channel, newest first. 233 + * 234 + * @param channelId - Discord channel id. 235 + * @param limit - Maximum rows (default 40, max 200). 236 + * @param beforeMessageId - Optional cursor for pagination. 237 + * @returns Message rows with resolved reply context. 238 + */ 764 239 export function listDiscordBackread(channelId: string, limit = 40, beforeMessageId?: string): unknown[] { 765 240 const db = getDb() 766 241 const safeChannelId = String(channelId ?? "").trim() ··· 798 273 })) 799 274 } 800 275 276 + // ── mark ─────────────────────────────────────────────────────────────── 277 + 278 + type InboxStatus = "pending" | "seen" | "acted" | "ignored" 279 + type InboxAction = "none" | "replied" | "messaged" | "dismissed" | "noted" 280 + 281 + const VALID_ACTION = new Set<InboxAction>(["none", "replied", "messaged", "dismissed", "noted"]) 282 + 283 + /** 284 + * Updates decision state for a Discord inbox item. 285 + * 286 + * @param itemId - Inbox item id. 287 + * @param status - New status. 288 + * @param note - Optional decision note. 289 + * @param action - Action taken. 290 + */ 801 291 export function markDiscordItem(itemId: string, status: InboxStatus, note = "", action: InboxAction = "none"): void { 802 292 const safeItemId = String(itemId ?? "").trim() 803 293 if (!safeItemId) throw new Error("item_id is required") 804 - if (!VALID_STATUS.has(status)) throw new Error(`invalid status: ${status}`) 294 + if (!["pending", "seen", "acted", "ignored"].includes(status)) throw new Error(`invalid status: ${status}`) 805 295 if (!VALID_ACTION.has(action)) throw new Error(`invalid action: ${action}`) 806 296 807 297 const db = getDb() ··· 814 304 ).run(status, action, note || null, now, now, safeItemId) 815 305 } 816 306 307 + // ── channels listing / notes ─────────────────────────────────────────── 308 + 309 + /** 310 + * Lists configured Discord channels and DM channels with stored interactions. 311 + * 312 + * @returns Channel rows. 313 + */ 817 314 export function listDiscordChannels(): unknown[] { 818 315 ensureConfiguredChannelsMaterialized() 819 316 const db = getDb() ··· 849 346 .all(...configuredIds) 850 347 } 851 348 349 + /** 350 + * Sets or clears a persistent note for a Discord channel. 351 + * 352 + * @param channelId - Channel id to annotate. 353 + * @param note - Note text (empty string clears it). 354 + * @returns Updated channel row. 355 + */ 852 356 export function setDiscordChannelNote(channelId: string, note: string): Record<string, unknown> { 853 357 const safeChannelId = String(channelId ?? "").trim() 854 358 if (!safeChannelId) throw new Error("channel_id is required") ··· 880 384 } 881 385 } 882 386 387 + // ── batch digest ─────────────────────────────────────────────────────── 388 + 389 + const DEFAULT_SCAN_LIMIT = 50 390 + 391 + /** 392 + * Builds a formatted batch digest of recent Discord activity for the runner. 393 + * 394 + * @param params - Optional batch parameters. 395 + * @returns Formatted digest, or `null` when no new messages exist. 396 + */ 883 397 export function buildDiscordBatchDigest(params?: { 884 398 maxMessages?: number 885 399 pendingPreviewLimit?: number ··· 1064 578 } 1065 579 } 1066 580 581 + // ── send ─────────────────────────────────────────────────────────────── 582 + 583 + type ReplyMode = "auto" | "plain" | "explicit" 584 + 1067 585 function resolveReferenceMessage(channelId: string, sourceItemId?: string, referenceMessage?: string): string | null { 1068 586 const ref = referenceMessage?.trim() 1069 587 if (ref) { 1070 588 const db = getDb() 1071 589 1072 - // If it looks like a snowflake ID, verify it exists in cache 1073 590 if (/^\d+$/.test(ref)) { 1074 591 const exists = db 1075 592 .prepare(`select 1 from discord_messages where message_id = ?`) ··· 1077 594 return exists ? ref : null 1078 595 } 1079 596 1080 - // Try matching by message content (most recent in channel) 1081 597 const byContent = db 1082 598 .prepare( 1083 599 `select message_id from discord_messages ··· 1087 603 .get(channelId, `%${ref}%`) as { message_id?: string } | undefined 1088 604 if (byContent?.message_id) return byContent.message_id 1089 605 1090 - // Try matching by author username (most recent message in channel) 1091 606 const byUsername = db 1092 607 .prepare( 1093 608 `select message_id from discord_messages ··· 1109 624 1110 625 return row?.message_id?.trim() ? row.message_id : null 1111 626 } 627 + 628 + const AUTO_REPLY_STALE_MINUTES = 10 1112 629 1113 630 function shouldUseExplicitReference(channelId: string, sourceMessageId: string): boolean { 1114 631 const db = getDb() ··· 1182 699 return "auto" 1183 700 } 1184 701 702 + /** 703 + * Sends a Discord message with optional reply reference resolution. 704 + * 705 + * @param params - Send parameters including channel, content, and reply options. 706 + * @returns Send result with message id and resolved reference information. 707 + */ 708 + export async function sendDiscordMessage(params: { 709 + channelId?: string 710 + content: string 711 + sourceItemId?: string 712 + replyMode?: string 713 + referenceMessage?: string 714 + }): Promise<Record<string, unknown>> { 715 + let channelId = String(params.channelId ?? "").trim() 716 + if (!channelId && params.sourceItemId?.trim()) { 717 + const db = getDb() 718 + const row = db 719 + .prepare( 720 + `select m.channel_id 721 + from discord_items i 722 + join discord_messages m on m.message_id = i.message_id 723 + where i.item_id = ?`, 724 + ) 725 + .get(params.sourceItemId.trim()) as { channel_id?: string } | undefined 726 + channelId = row?.channel_id?.trim() ?? "" 727 + } 728 + if (!channelId) throw new Error("channel_id is required (or provide source_item_id that maps to one)") 729 + 730 + const content = String(params.content ?? "").trim() 731 + if (!content) throw new Error("content is required") 732 + 733 + const replyMode = normalizeReplyMode(params.replyMode) 734 + const explicitSourceItemId = params.sourceItemId?.trim() ? params.sourceItemId.trim() : null 735 + const inferredSourceItemId = explicitSourceItemId ? null : inferPendingDmItemId(channelId) 736 + const resolvedSourceItemId = explicitSourceItemId ?? inferredSourceItemId 737 + const referenceMessageId = await chooseMessageReference({ 738 + channelId, 739 + replyMode, 740 + sourceItemId: resolvedSourceItemId ?? undefined, 741 + referenceMessage: params.referenceMessage, 742 + }) 743 + 744 + const rest = makeRestClient() 745 + const botUserId = await getBotUserId(rest) 746 + 747 + const message = (await withDiscordRestRetry(`send message ${channelId}`, () => 748 + rest.post(Routes.channelMessages(channelId), { 749 + body: { 750 + content, 751 + ...(referenceMessageId 752 + ? { 753 + message_reference: { 754 + message_id: referenceMessageId, 755 + channel_id: channelId, 756 + fail_if_not_exists: false, 757 + }, 758 + allowed_mentions: { replied_user: false }, 759 + } 760 + : {}), 761 + }, 762 + }), 763 + )) as DiscordObject 764 + 765 + const ingest = ingestDiscordEvent( 766 + { 767 + message, 768 + channel: { 769 + id: channelId, 770 + type: message.guild_id == null ? 1 : null, 771 + guild_id: message.guild_id, 772 + }, 773 + is_dm: message.guild_id == null, 774 + author_is_bot: true, 775 + }, 776 + { botUserId }, 777 + ) 778 + 779 + if (resolvedSourceItemId) { 780 + const wasInferred = !explicitSourceItemId 781 + markDiscordItem( 782 + resolvedSourceItemId, 783 + "acted", 784 + `responded via discord_send (${replyMode}${referenceMessageId ? ", explicit" : ", plain"}${wasInferred ? ", inferred_dm_item" : ""})`, 785 + referenceMessageId ? "replied" : "messaged", 786 + ) 787 + } 788 + 789 + return { 790 + ok: true, 791 + sent_message_id: asString(message.id), 792 + channel_id: channelId, 793 + reply_mode: replyMode, 794 + used_reference_message_id: referenceMessageId, 795 + resolved_source_item_id: resolvedSourceItemId, 796 + inferred_source_item_id: inferredSourceItemId, 797 + stored: ingest.stored, 798 + } 799 + } 800 + 801 + // ── scan ─────────────────────────────────────────────────────────────── 802 + 803 + /** 804 + * Scans configured Discord channels via REST API and ingests messages. 805 + * 806 + * @param params - Scan parameters. 807 + * @returns Summary of scanned channels and fetched/stored messages. 808 + */ 1185 809 export async function scanDiscordChannels(params?: { 1186 810 limit?: number 1187 811 channelIds?: string[] | string ··· 1283 907 inbox_items: inboxItems, 1284 908 } 1285 909 } 1286 - 1287 - export async function sendDiscordMessage(params: { 1288 - channelId?: string 1289 - content: string 1290 - sourceItemId?: string 1291 - replyMode?: string 1292 - referenceMessage?: string 1293 - }): Promise<Record<string, unknown>> { 1294 - let channelId = String(params.channelId ?? "").trim() 1295 - if (!channelId && params.sourceItemId?.trim()) { 1296 - const db = getDb() 1297 - const row = db 1298 - .prepare( 1299 - `select m.channel_id 1300 - from discord_items i 1301 - join discord_messages m on m.message_id = i.message_id 1302 - where i.item_id = ?`, 1303 - ) 1304 - .get(params.sourceItemId.trim()) as { channel_id?: string } | undefined 1305 - channelId = row?.channel_id?.trim() ?? "" 1306 - } 1307 - if (!channelId) throw new Error("channel_id is required (or provide source_item_id that maps to one)") 1308 - 1309 - const content = String(params.content ?? "").trim() 1310 - if (!content) throw new Error("content is required") 1311 - 1312 - const replyMode = normalizeReplyMode(params.replyMode) 1313 - const explicitSourceItemId = params.sourceItemId?.trim() ? params.sourceItemId.trim() : null 1314 - const inferredSourceItemId = explicitSourceItemId ? null : inferPendingDmItemId(channelId) 1315 - const resolvedSourceItemId = explicitSourceItemId ?? inferredSourceItemId 1316 - const referenceMessageId = await chooseMessageReference({ 1317 - channelId, 1318 - replyMode, 1319 - sourceItemId: resolvedSourceItemId ?? undefined, 1320 - referenceMessage: params.referenceMessage, 1321 - }) 1322 - 1323 - const rest = makeRestClient() 1324 - const botUserId = await getBotUserId(rest) 1325 - 1326 - const message = (await withDiscordRestRetry(`send message ${channelId}`, () => 1327 - rest.post(Routes.channelMessages(channelId), { 1328 - body: { 1329 - content, 1330 - ...(referenceMessageId 1331 - ? { 1332 - message_reference: { 1333 - message_id: referenceMessageId, 1334 - channel_id: channelId, 1335 - fail_if_not_exists: false, 1336 - }, 1337 - allowed_mentions: { replied_user: false }, 1338 - } 1339 - : {}), 1340 - }, 1341 - }), 1342 - )) as DiscordObject 1343 - 1344 - const ingest = ingestDiscordEvent( 1345 - { 1346 - message, 1347 - channel: { 1348 - id: channelId, 1349 - type: message.guild_id == null ? 1 : null, 1350 - guild_id: message.guild_id, 1351 - }, 1352 - is_dm: message.guild_id == null, 1353 - author_is_bot: true, 1354 - }, 1355 - { botUserId }, 1356 - ) 1357 - 1358 - if (resolvedSourceItemId) { 1359 - const wasInferred = !explicitSourceItemId 1360 - markDiscordItem( 1361 - resolvedSourceItemId, 1362 - "acted", 1363 - `responded via discord_send (${replyMode}${referenceMessageId ? ", explicit" : ", plain"}${wasInferred ? ", inferred_dm_item" : ""})`, 1364 - referenceMessageId ? "replied" : "messaged", 1365 - ) 1366 - } 1367 - 1368 - return { 1369 - ok: true, 1370 - sent_message_id: asString(message.id), 1371 - channel_id: channelId, 1372 - reply_mode: replyMode, 1373 - used_reference_message_id: referenceMessageId, 1374 - resolved_source_item_id: resolvedSourceItemId, 1375 - inferred_source_item_id: inferredSourceItemId, 1376 - stored: ingest.stored, 1377 - } 1378 - }
+1 -1
src/embeddings.ts
··· 1 1 import OpenAI from "openai" 2 - import { MEMORY_EMBEDDING_DIMENSIONS } from "./db.js" 2 + import { MEMORY_EMBEDDING_DIMENSIONS } from "./db" 3 3 4 4 export const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL ?? "google/gemini-embedding-2-preview" 5 5 export const EMBEDDING_DIMENSIONS = parseInt(
+7 -7
src/index.ts
··· 1 - import { openBash, closeBash } from "./container/index.js" 2 - import { createServer } from "./server.js" 3 - import { initDb } from "./db.js" 4 - import { initMetricsDb } from "./metrics.js" 5 - import { shutdown } from "./runner/index.js" 6 - import { startDiscordGateway } from "./discord/gateway.js" 7 - import { ensureSoulFilePlacement } from "./bootstrap.js" 1 + import { openBash, closeBash } from "./container/index" 2 + import { createServer } from "./server" 3 + import { initDb } from "./db" 4 + import { initMetricsDb } from "./metrics" 5 + import { shutdown } from "./runner/index" 6 + import { startDiscordGateway } from "./discord/gateway" 7 + import { ensureSoulFilePlacement } from "./bootstrap" 8 8 9 9 const PORT = parseInt(process.env.PORT ?? "3000") 10 10
+2 -2
src/memory.test.ts
··· 1 1 import test from "node:test" 2 2 import assert from "node:assert/strict" 3 - import type { Message } from "./types.js" 4 - import { __memoryTest } from "./memory.js" 3 + import type { Message } from "./types" 4 + import { __memoryTest } from "./memory/index" 5 5 6 6 test("latestMemoryRecallQuery falls back past scheduled heartbeat", () => { 7 7 const conversation: Message[] = [
-1585
src/memory.ts
··· 1 - import fs from "fs/promises" 2 - import path from "path" 3 - import { createHash } from "crypto" 4 - import { HOME_DIR } from "./container/config.js" 5 - import type { Message } from "./types.js" 6 - import { getDb, isVecAvailable, MEMORY_EMBEDDING_DIMENSIONS } from "./db.js" 7 - import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL, embeddingsConfigured, embedTexts } from "./embeddings.js" 8 - import { recordMetric } from "./metrics.js" 9 - 10 - const MEMORIES_DIR = path.join(HOME_DIR, "memories") 11 - const JOURNAL_DIR = path.join(MEMORIES_DIR, "journal") 12 - const PEOPLE_DIR = path.join(MEMORIES_DIR, "people") 13 - const CORE_FILE = path.join(MEMORIES_DIR, "core.md") 14 - const ALIASES_FILE = path.join(MEMORIES_DIR, "aliases.json") 15 - 16 - const MEMORY_RECALL_HEADER = "[memory recall v1]" 17 - const MEMORY_RECALL_NOTE = 18 - "Potentially relevant long-term notes. Use only if helpful; trust newer conversation details if anything conflicts." 19 - const MEMORY_RECALL_MAX_CHUNKS = 4 20 - const MEMORY_RECALL_MAX_CHUNKS_HARD_CAP = 8 21 - const MEMORY_RECALL_MAX_CHARS = 1_500 22 - const MEMORY_RECALL_PER_EXTRA_PERSON_CHARS = 400 23 - const MEMORY_QUERY_TOKEN_LIMIT = 12 24 - const MEMORY_RECALL_COOLDOWN_TURNS = 7 25 - const MEMORY_EMBEDDING_BATCH_SIZE = 24 26 - const MEMORY_SEMANTIC_MIN_SIMILARITY = 0.18 27 - const MEMORY_SEMANTIC_STRONG_SIMILARITY = 0.32 28 - const MEMORY_CHATTER_SIMILARITY_THRESHOLD = 0.74 29 - const MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD = 0.55 30 - const SCHEDULED_HEARTBEAT_CONTENT = "Scheduled heartbeat." 31 - const MEMORY_EMBEDDING_PROTOTYPES = [ 32 - { id: 1, name: "affection-love", category: "chatter", text: "i love you so much sweetie <33" }, 33 - { id: 2, name: "cat-greeting", category: "chatter", text: "boop mraow meow hi sweetie" }, 34 - { id: 3, name: "celebration", category: "chatter", text: "yay yayy lets gooooo <33" }, 35 - { id: 4, name: "goodnight", category: "chatter", text: "goodnight sweet dreams rest well" }, 36 - { id: 101, name: "who-person", category: "recall_intent", text: "who is this person what do i know about them" }, 37 - { id: 102, name: "past-event", category: "recall_intent", text: "what happened before remember when that event happened" }, 38 - { id: 103, name: "task-context", category: "recall_intent", text: "what context do i need for this task or project" }, 39 - { id: 104, name: "system-lesson", category: "recall_intent", text: "what lesson or instruction should i remember here" }, 40 - ] as const 41 - const MEMORY_STOP_WORDS = new Set([ 42 - "a", 43 - "an", 44 - "and", 45 - "are", 46 - "as", 47 - "at", 48 - "be", 49 - "been", 50 - "but", 51 - "by", 52 - "for", 53 - "from", 54 - "had", 55 - "has", 56 - "have", 57 - "he", 58 - "her", 59 - "hers", 60 - "him", 61 - "his", 62 - "i", 63 - "if", 64 - "in", 65 - "into", 66 - "is", 67 - "it", 68 - "its", 69 - "me", 70 - "my", 71 - "of", 72 - "on", 73 - "or", 74 - "our", 75 - "she", 76 - "that", 77 - "the", 78 - "their", 79 - "them", 80 - "there", 81 - "they", 82 - "this", 83 - "to", 84 - "up", 85 - "us", 86 - "was", 87 - "we", 88 - "were", 89 - "with", 90 - "you", 91 - "your", 92 - ]) 93 - 94 - type MemoryKind = "core" | "journal" | "people" 95 - 96 - type MemoryDocumentRow = { 97 - id: number 98 - path: string 99 - content_hash: string 100 - mtime_ms: number 101 - kind?: string 102 - title?: string 103 - } 104 - 105 - type MemoryChunkInput = { 106 - title: string 107 - headingPath: string | null 108 - text: string 109 - tags: string 110 - } 111 - 112 - type MemoryHit = { 113 - chunkId: number 114 - path: string 115 - kind: MemoryKind 116 - documentTitle: string 117 - title: string 118 - headingPath: string | null 119 - text: string 120 - rank: number 121 - semanticDistance?: number 122 - semanticSimilarity?: number 123 - } 124 - 125 - export type MemorySearchResult = { 126 - chunkId: number 127 - kind: MemoryKind 128 - path: string 129 - source: string 130 - title: string 131 - headingPath: string | null 132 - preview: string 133 - } 134 - 135 - type MemoryQueryParts = { 136 - sender: string | null 137 - source: string | null 138 - body: string 139 - } 140 - 141 - type MemorySearchProfile = { 142 - normalized: string 143 - sender: string | null 144 - senderAliases: string[] 145 - bodyTokens: string[] 146 - bodyPeople: string[] 147 - tokens: string[] 148 - personQuery: boolean 149 - eventQuery: boolean 150 - bodyInformative: boolean 151 - } 152 - 153 - type MemoryHitSignal = { 154 - overlap: number 155 - strongOverlap: boolean 156 - bodyOverlap: number 157 - senderMatch: boolean 158 - } 159 - 160 - type MemoryEmbeddingRow = { 161 - chunkId: number 162 - path: string 163 - kind: MemoryKind 164 - documentTitle: string 165 - title: string 166 - headingPath: string | null 167 - text: string 168 - tags: string | null 169 - model: string | null 170 - dimensions: number | null 171 - contentHash: string | null 172 - } 173 - 174 - type SemanticQuerySignal = { 175 - vector: number[] 176 - chatterSimilarity: number | null 177 - recallIntentSimilarity: number | null 178 - } 179 - 180 - export type AliasMap = Record<string, string[]> 181 - 182 - function normalizeText(value: string): string { 183 - return value.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim() 184 - } 185 - 186 - function trimForPrompt(value: string, maxChars: number): string { 187 - if (value.length <= maxChars) return value 188 - if (maxChars <= 3) return ".".repeat(maxChars) 189 - return `${value.slice(0, maxChars - 3).trimEnd()}...` 190 - } 191 - 192 - function basenameWithoutExt(filePath: string): string { 193 - return path.basename(filePath, path.extname(filePath)) 194 - } 195 - 196 - function titleFromPath(filePath: string, fallback: string): string { 197 - const base = basenameWithoutExt(filePath).replace(/[-_]+/g, " ").trim() 198 - return base ? base : fallback 199 - } 200 - 201 - function detectMemoryKind(filePath: string): MemoryKind | null { 202 - if (filePath === CORE_FILE) return "core" 203 - if (filePath.startsWith(`${JOURNAL_DIR}${path.sep}`)) return "journal" 204 - if (filePath.startsWith(`${PEOPLE_DIR}${path.sep}`)) return "people" 205 - return null 206 - } 207 - 208 - async function pathExists(target: string): Promise<boolean> { 209 - try { 210 - await fs.access(target) 211 - return true 212 - } catch { 213 - return false 214 - } 215 - } 216 - 217 - function normalizeHandle(handle: string): string { 218 - return handle.trim().replace(/^@+/, "").toLowerCase() 219 - } 220 - 221 - let aliasCache: { mtimeMs: number; map: AliasMap } | null = null 222 - 223 - async function loadAliasMap(): Promise<AliasMap> { 224 - try { 225 - const stat = await fs.stat(ALIASES_FILE) 226 - if (aliasCache && aliasCache.mtimeMs === stat.mtimeMs) return aliasCache.map 227 - const raw = await fs.readFile(ALIASES_FILE, "utf-8") 228 - const parsed = JSON.parse(raw) as unknown 229 - const map: AliasMap = {} 230 - if (parsed && typeof parsed === "object") { 231 - for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { 232 - const handle = normalizeHandle(key) 233 - if (!handle) continue 234 - const list = Array.isArray(value) ? value : [value] 235 - const aliases = list 236 - .map((v) => (typeof v === "string" ? normalizeHandle(v) : "")) 237 - .filter((v) => v && v !== handle) 238 - if (aliases.length > 0) map[handle] = Array.from(new Set(aliases)) 239 - } 240 - } 241 - aliasCache = { mtimeMs: stat.mtimeMs, map } 242 - return map 243 - } catch { 244 - aliasCache = { mtimeMs: 0, map: {} } 245 - return {} 246 - } 247 - } 248 - 249 - async function writeAliasMap(map: AliasMap): Promise<void> { 250 - await fs.mkdir(MEMORIES_DIR, { recursive: true }) 251 - const sorted: AliasMap = {} 252 - for (const key of Object.keys(map).sort()) sorted[key] = [...map[key]!].sort() 253 - await fs.writeFile(ALIASES_FILE, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8") 254 - aliasCache = null 255 - } 256 - 257 - function resolveAliases(handle: string | null, map: AliasMap): string[] { 258 - if (!handle) return [] 259 - const seen = new Set<string>([handle]) 260 - const out: string[] = [] 261 - const queue = [handle] 262 - while (queue.length > 0) { 263 - const current = queue.shift()! 264 - const next = map[current] ?? [] 265 - for (const alias of next) { 266 - if (seen.has(alias)) continue 267 - seen.add(alias) 268 - out.push(alias) 269 - queue.push(alias) 270 - } 271 - } 272 - return out 273 - } 274 - 275 - export async function listAliases(): Promise<AliasMap> { 276 - return loadAliasMap() 277 - } 278 - 279 - export async function setAlias(handle: string, canonical: string): Promise<AliasMap> { 280 - const h = normalizeHandle(handle) 281 - const c = normalizeHandle(canonical) 282 - if (!h || !c) throw new Error("alias handle and canonical must be non-empty") 283 - const map = await loadAliasMap() 284 - if (h === c) return map 285 - const existing = new Set(map[h] ?? []) 286 - existing.add(c) 287 - map[h] = Array.from(existing) 288 - await writeAliasMap(map) 289 - return map 290 - } 291 - 292 - export async function removeAlias(handle: string, canonical?: string): Promise<AliasMap> { 293 - const h = normalizeHandle(handle) 294 - if (!h) throw new Error("alias handle must be non-empty") 295 - const map = await loadAliasMap() 296 - if (!map[h]) return map 297 - if (canonical) { 298 - const c = normalizeHandle(canonical) 299 - map[h] = map[h]!.filter((entry) => entry !== c) 300 - if (map[h]!.length === 0) delete map[h] 301 - } else { 302 - delete map[h] 303 - } 304 - await writeAliasMap(map) 305 - return map 306 - } 307 - 308 - async function walkMarkdownFiles(root: string): Promise<string[]> { 309 - if (!(await pathExists(root))) return [] 310 - 311 - const found: string[] = [] 312 - const entries = await fs.readdir(root, { withFileTypes: true }) 313 - for (const entry of entries) { 314 - const fullPath = path.join(root, entry.name) 315 - if (entry.isDirectory()) { 316 - found.push(...(await walkMarkdownFiles(fullPath))) 317 - continue 318 - } 319 - if (entry.isFile() && entry.name.endsWith(".md")) found.push(fullPath) 320 - } 321 - 322 - return found.sort() 323 - } 324 - 325 - async function listMemoryFiles(): Promise<string[]> { 326 - const files: string[] = [] 327 - if (await pathExists(CORE_FILE)) files.push(CORE_FILE) 328 - files.push(...(await walkMarkdownFiles(JOURNAL_DIR))) 329 - files.push(...(await walkMarkdownFiles(PEOPLE_DIR))) 330 - return files 331 - } 332 - 333 - function contentHash(content: string): string { 334 - return createHash("sha1").update(content).digest("hex") 335 - } 336 - 337 - function embeddingInputHash(content: string): string { 338 - return createHash("sha256").update(content).digest("hex") 339 - } 340 - 341 - function vectorParam(vector: number[]): Float32Array { 342 - return new Float32Array(vector) 343 - } 344 - 345 - function embeddingTextForChunk(row: { 346 - path: string 347 - kind: MemoryKind 348 - documentTitle: string 349 - title: string 350 - headingPath: string | null 351 - text: string 352 - tags?: string | null 353 - }): string { 354 - const relativePath = path.relative(HOME_DIR, row.path) 355 - return [ 356 - `kind: ${row.kind}`, 357 - `file: ${relativePath}`, 358 - `document: ${row.documentTitle}`, 359 - `title: ${row.title}`, 360 - row.headingPath ? `section: ${row.headingPath}` : null, 361 - row.tags ? `tags: ${row.tags}` : null, 362 - "", 363 - row.text, 364 - ] 365 - .filter((part): part is string => part !== null) 366 - .join("\n") 367 - } 368 - 369 - function chunkLargeSection(text: string, maxChars = 900): string[] { 370 - const paragraphs = text 371 - .split(/\n\s*\n/g) 372 - .map((part) => part.trim()) 373 - .filter(Boolean) 374 - 375 - if (paragraphs.length === 0) return [] 376 - 377 - const chunks: string[] = [] 378 - let current = "" 379 - 380 - for (const paragraph of paragraphs) { 381 - const next = current ? `${current}\n\n${paragraph}` : paragraph 382 - if (next.length <= maxChars || current.length === 0) { 383 - current = next 384 - continue 385 - } 386 - chunks.push(current) 387 - current = paragraph 388 - } 389 - 390 - if (current) chunks.push(current) 391 - return chunks 392 - } 393 - 394 - function parseMarkdownDocument(filePath: string, content: string): { title: string; chunks: MemoryChunkInput[] } { 395 - const lines = content.replace(/\r\n/g, "\n").split("\n") 396 - const h1 = lines.find((line) => /^#\s+/.test(line)) 397 - const title = h1 ? h1.replace(/^#\s+/, "").trim() : titleFromPath(filePath, "Memory") 398 - const headingStack: string[] = [] 399 - let sectionLines: string[] = [] 400 - let sectionTitle = title 401 - const chunks: MemoryChunkInput[] = [] 402 - 403 - const flushSection = () => { 404 - const body = sectionLines.join("\n").trim() 405 - if (!body) { 406 - sectionLines = [] 407 - return 408 - } 409 - 410 - const headingPath = headingStack.length > 0 ? headingStack.join(" > ") : null 411 - const tags = [basenameWithoutExt(filePath), ...headingStack].join(" ").trim() 412 - for (const part of chunkLargeSection(body)) { 413 - chunks.push({ 414 - title: sectionTitle || title, 415 - headingPath, 416 - text: part, 417 - tags, 418 - }) 419 - } 420 - sectionLines = [] 421 - } 422 - 423 - for (const line of lines) { 424 - const headingMatch = line.match(/^(#{1,6})\s+(.*)$/) 425 - if (!headingMatch) { 426 - sectionLines.push(line) 427 - continue 428 - } 429 - 430 - flushSection() 431 - 432 - const level = headingMatch[1]!.length 433 - const heading = headingMatch[2]!.trim() 434 - if (level === 1) { 435 - sectionTitle = heading || title 436 - headingStack.length = 0 437 - continue 438 - } 439 - 440 - while (headingStack.length >= level - 1) headingStack.pop() 441 - headingStack.push(heading) 442 - sectionTitle = heading || title 443 - } 444 - 445 - flushSection() 446 - 447 - if (chunks.length === 0) { 448 - const body = content.trim() 449 - if (body) { 450 - chunks.push({ 451 - title, 452 - headingPath: null, 453 - text: body, 454 - tags: basenameWithoutExt(filePath), 455 - }) 456 - } 457 - } 458 - 459 - return { title, chunks } 460 - } 461 - 462 - function memoryRecallCooldownTurns(kind: MemoryKind): number { 463 - if (kind === "people" || kind === "journal") return MEMORY_RECALL_COOLDOWN_TURNS 464 - return 0 465 - } 466 - 467 - function isCoolingDown(hit: MemoryHit, cooldowns: Record<number, number>, currentTurn: number): boolean { 468 - const lastTurn = cooldowns[hit.chunkId] 469 - if (typeof lastTurn !== "number") return false 470 - return currentTurn - lastTurn < memoryRecallCooldownTurns(hit.kind) 471 - } 472 - 473 - function isMemoryRecallSkippedMessage(content: string): boolean { 474 - if (content.startsWith(MEMORY_RECALL_HEADER)) return true 475 - if (content.startsWith("[system]")) return true 476 - if (content.includes("scan snapshot:") && !content.includes("[discord batch]")) return true 477 - if (/\[discord batch\]/i.test(content) && conciseDiscordBatchMemoryQuery(content) === null) return true 478 - return false 479 - } 480 - 481 - function latestMemoryRecallQuery(conversation: Message[]): string | null { 482 - for (let i = conversation.length - 1; i >= 0; i -= 1) { 483 - const message = conversation[i] 484 - if (!message || message.role !== "user") continue 485 - if (typeof message.content !== "string") continue 486 - 487 - const content = message.content.trim() 488 - if (!content || isMemoryRecallSkippedMessage(content)) continue 489 - if (content === SCHEDULED_HEARTBEAT_CONTENT) continue 490 - return content 491 - } 492 - return null 493 - } 494 - 495 - function discordChannelLabel(channelId: string | null, fallbackContext: string | null, isDm: boolean): string { 496 - if (isDm) return "DM" 497 - 498 - const fallback = fallbackContext 499 - ?.replace(/^context:\s*/i, "") 500 - .replace(/\s*\(\d+\)\s*$/, "") 501 - .trim() 502 - 503 - if (channelId) { 504 - try { 505 - const row = getDb() 506 - .prepare("select guild_id, guild_name, channel_name, is_dm from discord_channels where channel_id = ?") 507 - .get(channelId) as 508 - | { 509 - guild_id: string | null 510 - guild_name: string | null 511 - channel_name: string | null 512 - is_dm: number 513 - } 514 - | undefined 515 - 516 - if (row?.is_dm) return "DM" 517 - if (row) { 518 - const guild = row.guild_name ?? row.guild_id 519 - const channel = row.channel_name ?? channelId 520 - if (guild && channel) return `${guild}/#${channel}` 521 - if (channel) return `#${channel}` 522 - } 523 - } catch { 524 - // If the main db is unavailable in tests or scripts, keep the parsed context. 525 - } 526 - } 527 - 528 - if (fallback) return fallback 529 - return channelId ? `#${channelId}` : "channel" 530 - } 531 - 532 - function conciseDiscordMemoryQuery(raw: string): MemoryQueryParts | null { 533 - const withoutWakeEnvelope = stripWakeEnvelope(raw) 534 - if (!/\[discord\/(?:dm|channel)\]/i.test(withoutWakeEnvelope)) return null 535 - 536 - const blocks = withoutWakeEnvelope 537 - .split(/\n\s*\n/g) 538 - .map((block) => block.trim()) 539 - .filter(Boolean) 540 - const headerBlock = blocks[0] ?? withoutWakeEnvelope 541 - const message = blocks.length > 1 ? blocks.slice(1).join("\n\n").trim() : "" 542 - 543 - const lines = headerBlock.split("\n").map((line) => line.trim()).filter(Boolean) 544 - const discordLine = lines.find((line) => /^\[discord\/(?:dm|channel)\]/i.test(line)) ?? "" 545 - const contextLine = lines.find((line) => /^context:\s*/i.test(line)) ?? null 546 - const isDm = /\[discord\/dm\]/i.test(discordLine) 547 - const author = discordLine.match(/@(\S+)/)?.[1] ?? null 548 - const context = contextLine?.replace(/^context:\s*/i, "").trim() ?? "" 549 - const dmChannelId = context.match(/^DM\s+(\d+)/i)?.[1] ?? null 550 - const namedChannelId = context.match(/\((\d+)\)\s*$/)?.[1] ?? null 551 - const channelId = dmChannelId ?? namedChannelId 552 - const location = discordChannelLabel(channelId, contextLine, isDm) 553 - 554 - if (!author && !location && !message) return null 555 - return { 556 - sender: author ? normalizeHandle(author) : null, 557 - source: location || null, 558 - body: message, 559 - } 560 - } 561 - 562 - function extractBulletSection(raw: string, label: string): string[] { 563 - const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 564 - const match = raw.match(new RegExp(`(?:^|\\n)${escapedLabel}:\\n([\\s\\S]*?)(?:\\n\\n[^\\n:]+:|$)`, "i")) 565 - if (!match) return [] 566 - 567 - return match[1] 568 - .split("\n") 569 - .map((line) => line.trim()) 570 - .filter((line) => line.startsWith("- ")) 571 - .map((line) => line.slice(2).trim()) 572 - .filter((line) => line && line !== "(none)") 573 - } 574 - 575 - function conciseDiscordBatchMemoryQuery(raw: string): MemoryQueryParts | null { 576 - const withoutWakeEnvelope = stripWakeEnvelope(raw) 577 - if (!/\[discord batch\]/i.test(withoutWakeEnvelope)) return null 578 - 579 - const pending = extractBulletSection(withoutWakeEnvelope, "pending preview") 580 - const selected = pending.filter((entry) => !/^\(none\)$/i.test(entry)).slice(-3) 581 - if (selected.length === 0) return null 582 - 583 - const senders: string[] = [] 584 - const sources: string[] = [] 585 - const bodies: string[] = [] 586 - const entryPattern = /(?:^|\s)\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*(.*)$/i 587 - for (const entry of selected) { 588 - const match = entry.match(entryPattern) 589 - if (!match) { 590 - bodies.push(entry) 591 - continue 592 - } 593 - const [, location, author, body] = match 594 - if (author) senders.push(normalizeHandle(author)) 595 - if (location) sources.push(location.trim()) 596 - if (body) bodies.push(body.trim()) 597 - } 598 - 599 - const lastSender = senders.length > 0 ? senders[senders.length - 1]! : null 600 - const lastSource = sources.length > 0 ? sources[sources.length - 1]! : null 601 - const body = bodies.filter(Boolean).join("\n").trim() 602 - 603 - if (!lastSender && !lastSource && !body) return null 604 - return { sender: lastSender, source: lastSource, body } 605 - } 606 - 607 - const WAKE_ENVELOPE_PATTERN = /^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi 608 - 609 - function stripWakeEnvelope(raw: string): string { 610 - return raw.replace(WAKE_ENVELOPE_PATTERN, "").trim() 611 - } 612 - 613 - function memoryQueryForUserMessage(raw: string): MemoryQueryParts { 614 - return ( 615 - conciseDiscordMemoryQuery(raw) ?? 616 - conciseDiscordBatchMemoryQuery(raw) ?? 617 - { sender: null, source: null, body: stripWakeEnvelope(raw) } 618 - ) 619 - } 620 - 621 - function memoryQueryToString(parts: MemoryQueryParts): string { 622 - const pieces = [ 623 - parts.sender ? `@${parts.sender}` : null, 624 - parts.body || null, 625 - ].filter((value): value is string => Boolean(value && value.trim())) 626 - return pieces.join("\n") 627 - } 628 - 629 - function normalizeBodyText(raw: string): string { 630 - return raw 631 - .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 632 - .replace(/\b\d{6,}\b/g, " ") 633 - .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 634 - .toLowerCase() 635 - .trim() 636 - } 637 - 638 - function tokensFromText(raw: string): string[] { 639 - const clean = normalizeBodyText(raw) 640 - const tokens = clean 641 - .split(/\s+/) 642 - .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 643 - .filter((token) => token.length >= 2 || /\d{2,}/.test(token)) 644 - .filter((token) => !MEMORY_STOP_WORDS.has(token)) 645 - 646 - const unique: string[] = [] 647 - const seen = new Set<string>() 648 - for (const token of tokens) { 649 - if (seen.has(token)) continue 650 - seen.add(token) 651 - unique.push(token) 652 - if (unique.length >= MEMORY_QUERY_TOKEN_LIMIT) break 653 - } 654 - return unique 655 - } 656 - 657 - function searchTokens(raw: string): string[] { 658 - return tokensFromText(raw) 659 - } 660 - 661 - const BODY_INFORMATIVE_BM25_THRESHOLD = -5 662 - 663 - function bestBodyBm25(bodyTokens: string[]): number | null { 664 - if (bodyTokens.length === 0) return null 665 - const query = bodyTokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" OR ") 666 - try { 667 - const row = getDb() 668 - .prepare( 669 - "select bm25(memory_chunks_fts, 5.0, 2.0, 1.0, 0.5) as r from memory_chunks_fts where memory_chunks_fts match ? order by r limit 1", 670 - ) 671 - .get(query) as { r: number } | undefined 672 - return row?.r ?? null 673 - } catch { 674 - return null 675 - } 676 - } 677 - 678 - function computeBodyInformativeness(bodyTokens: string[], bodyPeople: string[]): boolean { 679 - if (bodyPeople.length > 0) return true 680 - if (bodyTokens.length === 0) return false 681 - const top = bestBodyBm25(bodyTokens) 682 - if (top === null) return false 683 - return top <= BODY_INFORMATIVE_BM25_THRESHOLD 684 - } 685 - 686 - async function knownPeopleHandles(aliasMap: AliasMap): Promise<Set<string>> { 687 - const handles = new Set<string>() 688 - if (await pathExists(PEOPLE_DIR)) { 689 - const entries = await fs.readdir(PEOPLE_DIR, { withFileTypes: true }) 690 - for (const entry of entries) { 691 - if (!entry.isFile() || !entry.name.endsWith(".md")) continue 692 - const base = basenameWithoutExt(entry.name).toLowerCase() 693 - if (base) handles.add(base) 694 - } 695 - } 696 - for (const [key, values] of Object.entries(aliasMap)) { 697 - handles.add(key) 698 - for (const value of values) handles.add(value) 699 - } 700 - return handles 701 - } 702 - 703 - async function buildSearchProfile(parts: MemoryQueryParts): Promise<MemorySearchProfile> { 704 - const aliasMap = await loadAliasMap() 705 - const sender = parts.sender ? normalizeHandle(parts.sender) : null 706 - const senderAliases = resolveAliases(sender, aliasMap) 707 - const bodyTokens = parts.body ? tokensFromText(parts.body) : [] 708 - 709 - const known = await knownPeopleHandles(aliasMap) 710 - const inlineMentions = parts.body 711 - ? Array.from(parts.body.matchAll(/@([a-z0-9_.-]+)/gi)).map((match) => normalizeHandle(match[1]!)) 712 - : [] 713 - const bodyPeopleSet = new Set<string>() 714 - const senderSet = new Set<string>([sender ?? "", ...senderAliases].filter(Boolean)) 715 - for (const token of [...bodyTokens, ...inlineMentions]) { 716 - if (!token || senderSet.has(token)) continue 717 - if (known.has(token)) bodyPeopleSet.add(token) 718 - } 719 - const bodyPeople = Array.from(bodyPeopleSet) 720 - for (const person of [...bodyPeople]) { 721 - for (const alias of resolveAliases(person, aliasMap)) { 722 - if (!senderSet.has(alias)) bodyPeopleSet.add(alias) 723 - } 724 - } 725 - const bodyPeopleResolved = Array.from(bodyPeopleSet) 726 - 727 - const combined: string[] = [] 728 - const seen = new Set<string>() 729 - const push = (value: string | null | undefined) => { 730 - if (!value) return 731 - const lower = value.toLowerCase() 732 - if (seen.has(lower)) return 733 - seen.add(lower) 734 - combined.push(lower) 735 - } 736 - push(sender) 737 - for (const alias of senderAliases) push(alias) 738 - for (const person of bodyPeopleResolved) push(person) 739 - for (const token of bodyTokens) push(token) 740 - 741 - const normalized = [sender ? `@${sender}` : "", parts.source ?? "", parts.body ?? ""] 742 - .filter(Boolean) 743 - .join(" ") 744 - .toLowerCase() 745 - 746 - const bodyInformative = computeBodyInformativeness(bodyTokens, bodyPeopleResolved) 747 - 748 - return { 749 - normalized, 750 - sender, 751 - senderAliases, 752 - bodyTokens, 753 - bodyPeople: bodyPeopleResolved, 754 - tokens: combined.slice(0, MEMORY_QUERY_TOKEN_LIMIT), 755 - bodyInformative, 756 - personQuery: 757 - Boolean(sender) || 758 - bodyPeopleResolved.length > 0 || 759 - /\b(who is|who's|tell me about|about)\b/.test(normalized) || 760 - /\bname\b/.test(normalized) || 761 - /\bfriend\b/.test(normalized), 762 - eventQuery: 763 - /\b(what happened|when|yesterday|today|tonight|earlier|before|after|session|wake)\b/.test( 764 - normalized, 765 - ) || 766 - /\b\d{4}-\d{2}-\d{2}\b/.test(normalized) || 767 - /\b\d{1,2}\/\d{1,2}(?:\/\d{2,4})?\b/.test(normalized) || 768 - /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/.test( 769 - normalized, 770 - ), 771 - } 772 - } 773 - 774 - function buildSearchQuery(profile: MemorySearchProfile): string | null { 775 - if (profile.tokens.length === 0) return null 776 - return profile.tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" OR ") 777 - } 778 - 779 - async function readMemoryDocumentRows(): Promise<Map<string, MemoryDocumentRow>> { 780 - const rows = getDb() 781 - .prepare("select id, path, kind, title, content_hash, mtime_ms from memory_documents") 782 - .all() as MemoryDocumentRow[] 783 - 784 - return new Map(rows.map((row) => [row.path, row])) 785 - } 786 - 787 - export async function syncMemoryIndex(): Promise<void> { 788 - const db = getDb() 789 - const files = await listMemoryFiles() 790 - const known = await readMemoryDocumentRows() 791 - const present = new Set(files) 792 - 793 - const deleteChunksByDocument = db.prepare("delete from memory_chunks where document_id = ?") 794 - const insertDocument = db.prepare(` 795 - insert into memory_documents (path, kind, title, mtime_ms, content_hash, updated_at) 796 - values (@path, @kind, @title, @mtime_ms, @content_hash, datetime('now')) 797 - on conflict(path) do update set 798 - kind = excluded.kind, 799 - title = excluded.title, 800 - mtime_ms = excluded.mtime_ms, 801 - content_hash = excluded.content_hash, 802 - updated_at = datetime('now') 803 - `) 804 - const selectDocumentId = db.prepare("select id from memory_documents where path = ?") 805 - const insertChunk = db.prepare(` 806 - insert into memory_chunks (document_id, chunk_index, title, heading_path, chunk_text, tags) 807 - values (?, ?, ?, ?, ?, ?) 808 - `) 809 - const deleteDocumentByPath = db.prepare("delete from memory_documents where path = ?") 810 - 811 - const updates: Array<{ 812 - path: string 813 - kind: MemoryKind 814 - title: string 815 - mtimeMs: number 816 - hash: string 817 - chunks: MemoryChunkInput[] 818 - action: "inserted" | "updated" 819 - }> = [] 820 - 821 - for (const filePath of files) { 822 - const kind = detectMemoryKind(filePath) 823 - if (!kind) continue 824 - 825 - const [content, stat] = await Promise.all([fs.readFile(filePath, "utf-8"), fs.stat(filePath)]) 826 - const hash = contentHash(content) 827 - const previous = known.get(filePath) 828 - if (previous && previous.content_hash === hash && previous.mtime_ms === Math.floor(stat.mtimeMs)) continue 829 - 830 - const parsed = parseMarkdownDocument(filePath, content) 831 - updates.push({ 832 - path: filePath, 833 - kind, 834 - title: parsed.title, 835 - mtimeMs: Math.floor(stat.mtimeMs), 836 - hash, 837 - chunks: parsed.chunks, 838 - action: previous ? "updated" : "inserted", 839 - }) 840 - } 841 - 842 - const removedPaths: string[] = [] 843 - 844 - db.transaction(() => { 845 - for (const item of updates) { 846 - insertDocument.run({ 847 - path: item.path, 848 - kind: item.kind, 849 - title: item.title, 850 - mtime_ms: item.mtimeMs, 851 - content_hash: item.hash, 852 - }) 853 - const row = selectDocumentId.get(item.path) as { id: number } | undefined 854 - if (!row) continue 855 - 856 - deleteChunksByDocument.run(row.id) 857 - item.chunks.forEach((chunk, index) => { 858 - insertChunk.run(row.id, index, chunk.title, chunk.headingPath, chunk.text, chunk.tags) 859 - }) 860 - 861 - console.log( 862 - `[memory] ${item.action} kind=${item.kind} chunks=${item.chunks.length} path=${path.relative(HOME_DIR, item.path)}`, 863 - ) 864 - } 865 - 866 - for (const filePath of known.keys()) { 867 - if (present.has(filePath)) continue 868 - deleteDocumentByPath.run(filePath) 869 - removedPaths.push(filePath) 870 - console.log(`[memory] removed path=${path.relative(HOME_DIR, filePath)}`) 871 - } 872 - })() 873 - 874 - if (updates.length === 0 && removedPaths.length === 0) { 875 - await syncMemoryEmbeddings() 876 - return 877 - } 878 - 879 - await syncMemoryEmbeddings() 880 - } 881 - 882 - let embeddingSkipWarned = false 883 - 884 - async function syncMemoryEmbeddings(): Promise<void> { 885 - if (!isVecAvailable()) return 886 - if (!embeddingsConfigured()) { 887 - if (!embeddingSkipWarned) { 888 - console.warn("[memory] embeddings disabled: set EMBEDDING_API_KEY") 889 - embeddingSkipWarned = true 890 - } 891 - return 892 - } 893 - if (EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) { 894 - if (!embeddingSkipWarned) { 895 - console.warn( 896 - `[memory] embeddings disabled: EMBEDDING_DIMENSIONS=${EMBEDDING_DIMENSIONS} but sqlite-vec table is ${MEMORY_EMBEDDING_DIMENSIONS}`, 897 - ) 898 - embeddingSkipWarned = true 899 - } 900 - return 901 - } 902 - 903 - const db = getDb() 904 - try { 905 - await syncMemoryEmbeddingPrototypes() 906 - } catch (err: any) { 907 - console.warn(`[memory] prototype embedding sync failed: ${err?.message ?? String(err)}`) 908 - return 909 - } 910 - db.prepare("delete from memory_embedding_meta where chunk_id not in (select id from memory_chunks)").run() 911 - db.prepare("delete from memory_chunk_vec where rowid not in (select id from memory_chunks)").run() 912 - 913 - const rows = db 914 - .prepare(` 915 - select 916 - c.id as chunkId, 917 - d.path as path, 918 - d.kind as kind, 919 - d.title as documentTitle, 920 - c.title as title, 921 - c.heading_path as headingPath, 922 - c.chunk_text as text, 923 - c.tags as tags, 924 - m.model as model, 925 - m.dimensions as dimensions, 926 - m.content_hash as contentHash 927 - from memory_chunks c 928 - join memory_documents d on d.id = c.document_id 929 - left join memory_embedding_meta m on m.chunk_id = c.id 930 - order by d.kind, d.path, c.chunk_index 931 - `) 932 - .all() as MemoryEmbeddingRow[] 933 - 934 - const pending = rows 935 - .map((row) => { 936 - const text = embeddingTextForChunk(row) 937 - return { ...row, embeddingText: text, embeddingHash: embeddingInputHash(text) } 938 - }) 939 - .filter( 940 - (row) => 941 - row.model !== EMBEDDING_MODEL || 942 - row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 943 - row.contentHash !== row.embeddingHash, 944 - ) 945 - 946 - if (pending.length === 0) return 947 - 948 - const upsertMeta = db.prepare(` 949 - insert into memory_embedding_meta (chunk_id, model, dimensions, content_hash, updated_at) 950 - values (?, ?, ?, ?, datetime('now')) 951 - on conflict(chunk_id) do update set 952 - model = excluded.model, 953 - dimensions = excluded.dimensions, 954 - content_hash = excluded.content_hash, 955 - updated_at = datetime('now') 956 - `) 957 - const upsertVector = db.prepare("insert or replace into memory_chunk_vec(rowid, embedding) values (?, ?)") 958 - 959 - let embedded = 0 960 - for (let i = 0; i < pending.length; i += MEMORY_EMBEDDING_BATCH_SIZE) { 961 - const batch = pending.slice(i, i + MEMORY_EMBEDDING_BATCH_SIZE) 962 - let vectors: number[][] 963 - try { 964 - vectors = await embedTexts(batch.map((row) => row.embeddingText)) 965 - } catch (err: any) { 966 - console.warn(`[memory] embedding batch failed: ${err?.message ?? String(err)}`) 967 - return 968 - } 969 - 970 - db.transaction(() => { 971 - batch.forEach((row, index) => { 972 - const vector = vectors[index] 973 - if (!vector) return 974 - if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 975 - throw new Error(`embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 976 - } 977 - upsertVector.run(BigInt(row.chunkId), vectorParam(vector)) 978 - upsertMeta.run(row.chunkId, EMBEDDING_MODEL, MEMORY_EMBEDDING_DIMENSIONS, row.embeddingHash) 979 - embedded += 1 980 - }) 981 - })() 982 - } 983 - 984 - console.log(`[memory] embedded chunks=${embedded} model=${EMBEDDING_MODEL} dimensions=${MEMORY_EMBEDDING_DIMENSIONS}`) 985 - } 986 - 987 - async function syncMemoryEmbeddingPrototypes(): Promise<void> { 988 - const db = getDb() 989 - const rows = db 990 - .prepare("select id, name, category, model, dimensions, content_hash as contentHash from memory_embedding_prototypes") 991 - .all() as Array<{ 992 - id: number 993 - name: string 994 - category: string 995 - model: string 996 - dimensions: number 997 - contentHash: string 998 - }> 999 - const known = new Map(rows.map((row) => [row.id, row])) 1000 - const pending = MEMORY_EMBEDDING_PROTOTYPES.map((prototype) => ({ 1001 - ...prototype, 1002 - hash: embeddingInputHash(`${prototype.category}\n${prototype.name}\n${prototype.text}`), 1003 - })).filter((prototype) => { 1004 - const row = known.get(prototype.id) 1005 - return ( 1006 - !row || 1007 - row.name !== prototype.name || 1008 - row.category !== prototype.category || 1009 - row.model !== EMBEDDING_MODEL || 1010 - row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 1011 - row.contentHash !== prototype.hash 1012 - ) 1013 - }) 1014 - 1015 - if (pending.length === 0) return 1016 - 1017 - const vectors = await embedTexts(pending.map((prototype) => prototype.text)) 1018 - const upsertPrototype = db.prepare(` 1019 - insert into memory_embedding_prototypes (id, name, category, model, dimensions, content_hash, updated_at) 1020 - values (?, ?, ?, ?, ?, ?, datetime('now')) 1021 - on conflict(id) do update set 1022 - name = excluded.name, 1023 - category = excluded.category, 1024 - model = excluded.model, 1025 - dimensions = excluded.dimensions, 1026 - content_hash = excluded.content_hash, 1027 - updated_at = datetime('now') 1028 - `) 1029 - const upsertVector = db.prepare("insert or replace into memory_prototype_vec(rowid, embedding) values (?, ?)") 1030 - 1031 - db.transaction(() => { 1032 - pending.forEach((prototype, index) => { 1033 - const vector = vectors[index] 1034 - if (!vector) return 1035 - if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 1036 - throw new Error(`prototype embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 1037 - } 1038 - upsertVector.run(BigInt(prototype.id), vectorParam(vector)) 1039 - upsertPrototype.run( 1040 - prototype.id, 1041 - prototype.name, 1042 - prototype.category, 1043 - EMBEDDING_MODEL, 1044 - MEMORY_EMBEDDING_DIMENSIONS, 1045 - prototype.hash, 1046 - ) 1047 - }) 1048 - })() 1049 - } 1050 - 1051 - function senderHandles(profile: MemorySearchProfile): string[] { 1052 - const out: string[] = [] 1053 - if (profile.sender) out.push(profile.sender) 1054 - for (const alias of profile.senderAliases) out.push(alias) 1055 - return out 1056 - } 1057 - 1058 - function allPersonHandles(profile: MemorySearchProfile): string[] { 1059 - const seen = new Set<string>() 1060 - const out: string[] = [] 1061 - for (const handle of [...senderHandles(profile), ...profile.bodyPeople]) { 1062 - if (!handle || seen.has(handle)) continue 1063 - seen.add(handle) 1064 - out.push(handle) 1065 - } 1066 - return out 1067 - } 1068 - 1069 - function targetPersonHandles(profile: MemorySearchProfile): string[] { 1070 - return profile.bodyPeople.length > 0 ? profile.bodyPeople : allPersonHandles(profile) 1071 - } 1072 - 1073 - function hitMatchesHandle(hit: MemoryHit, handle: string): boolean { 1074 - const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 1075 - const pathHaystack = hit.path.toLowerCase() 1076 - return titleHaystack.includes(handle) || pathHaystack.includes(`/${handle}.md`) 1077 - } 1078 - 1079 - function hitMentionsHandle(hit: MemoryHit, handle: string): boolean { 1080 - return hitMatchesHandle(hit, handle) || hitSearchHaystack(hit).includes(handle) 1081 - } 1082 - 1083 - function scoreMemoryHit(hit: MemoryHit, profile: MemorySearchProfile): number { 1084 - let score = hit.rank 1085 - 1086 - if (profile.personQuery && !profile.eventQuery) { 1087 - if (hit.kind === "people") score -= 2 1088 - if (hit.kind === "core") score -= 0.5 1089 - if (hit.kind === "journal") score += 1.5 1090 - } else if (profile.eventQuery && !profile.personQuery) { 1091 - if (hit.kind === "journal") score -= 1.5 1092 - if (hit.kind === "people") score += 0.75 1093 - if (hit.kind === "core") score += 0.25 1094 - } else { 1095 - if (hit.kind === "people") score -= 0.5 1096 - if (hit.kind === "core") score -= 0.25 1097 - } 1098 - 1099 - const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 1100 - if (profile.bodyTokens.some((token) => titleHaystack.includes(token))) score -= 0.35 1101 - 1102 - const handles = allPersonHandles(profile) 1103 - if (handles.length > 0) { 1104 - const fullHaystack = `${titleHaystack} ${hit.text.toLowerCase()}` 1105 - const pathHaystack = hit.path.toLowerCase() 1106 - if (handles.some((h) => titleHaystack.includes(h) || pathHaystack.includes(`/${h}.md`))) { 1107 - score -= 3 1108 - } else if (handles.some((h) => fullHaystack.includes(h))) { 1109 - score -= 1 1110 - } 1111 - } 1112 - 1113 - if (profile.bodyPeople.length > 0) { 1114 - const targetHandles = targetPersonHandles(profile) 1115 - const matchesTarget = targetHandles.some((handle) => hitMatchesHandle(hit, handle)) 1116 - if (matchesTarget) { 1117 - score -= 4 1118 - } else if (hit.kind === "people") { 1119 - score += 3 1120 - } else if (hit.kind === "core" && !profileHasCoreIntent(profile)) { 1121 - score += 3.5 1122 - } 1123 - } 1124 - 1125 - return score 1126 - } 1127 - 1128 - function hitSearchHaystack(hit: MemoryHit): string { 1129 - return `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)} ${hit.text}`.toLowerCase() 1130 - } 1131 - 1132 - function memoryHitSignal(hit: MemoryHit, profile: MemorySearchProfile): MemoryHitSignal { 1133 - const haystack = hitSearchHaystack(hit) 1134 - let overlap = 0 1135 - let strongOverlap = false 1136 - let bodyOverlap = 0 1137 - 1138 - for (const token of profile.tokens) { 1139 - if (!haystack.includes(token)) continue 1140 - overlap += 1 1141 - if (token.length >= 5 || /[0-9]/.test(token)) strongOverlap = true 1142 - } 1143 - for (const token of profile.bodyTokens) { 1144 - if (haystack.includes(token)) bodyOverlap += 1 1145 - } 1146 - 1147 - const handles = allPersonHandles(profile) 1148 - const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 1149 - const pathHaystack = hit.path.toLowerCase() 1150 - const senderMatch = 1151 - handles.length > 0 && 1152 - handles.some((h) => titleHaystack.includes(h) || pathHaystack.includes(`/${h}.md`) || haystack.includes(h)) 1153 - 1154 - return { overlap, strongOverlap, bodyOverlap, senderMatch } 1155 - } 1156 - 1157 - function shouldInjectHits(hits: MemoryHit[], profile: MemorySearchProfile): boolean { 1158 - if (hits.length === 0) return false 1159 - 1160 - const topSignal = memoryHitSignal(hits[0]!, profile) 1161 - 1162 - if (profile.sender || profile.bodyPeople.length > 0) { 1163 - if (!profile.bodyInformative) return false 1164 - if (topSignal.senderMatch) return true 1165 - if (topSignal.bodyOverlap >= 2) return true 1166 - if (topSignal.bodyOverlap >= 1 && topSignal.strongOverlap) return true 1167 - return false 1168 - } 1169 - 1170 - if (profile.eventQuery && !profile.personQuery) { 1171 - return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "journal") 1172 - } 1173 - 1174 - if (profile.personQuery && !profile.eventQuery) { 1175 - return topSignal.overlap >= 1 && hits.some((hit) => hit.kind === "people" || hit.kind === "core") 1176 - } 1177 - 1178 - if (topSignal.bodyOverlap >= 2) return true 1179 - if (topSignal.bodyOverlap >= 1 && topSignal.strongOverlap) return true 1180 - if (topSignal.overlap >= 2 && topSignal.strongOverlap) return true 1181 - return false 1182 - } 1183 - 1184 - function profileHasExplicitRecallIntent(profile: MemorySearchProfile): boolean { 1185 - if (profile.bodyPeople.length > 0) return true 1186 - if (profile.eventQuery) return true 1187 - if (/\b(who is|who's|tell me about|remember|recall|what happened|what do i know|context)\b/.test(profile.normalized)) { 1188 - return true 1189 - } 1190 - return false 1191 - } 1192 - 1193 - function profileHasCoreIntent(profile: MemorySearchProfile): boolean { 1194 - return /\b(core|identity|system|environment|lesson|rule|instruction|workflow|tool|config|memory)\b/.test(profile.normalized) 1195 - } 1196 - 1197 - async function semanticQuerySignal(memoryQuery: string): Promise<SemanticQuerySignal | null> { 1198 - if (!isVecAvailable() || !embeddingsConfigured() || EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) return null 1199 - const [vector] = await embedTexts([memoryQuery]) 1200 - if (!vector || vector.length !== MEMORY_EMBEDDING_DIMENSIONS) return null 1201 - 1202 - const rows = getDb() 1203 - .prepare(` 1204 - select p.category as category, v.distance as distance 1205 - from memory_prototype_vec v 1206 - join memory_embedding_prototypes p on p.id = v.rowid 1207 - where v.embedding match ? 1208 - and k = ? 1209 - order by v.distance 1210 - `) 1211 - .all(vectorParam(vector), 8) as Array<{ category: string; distance: number }> 1212 - 1213 - let chatterSimilarity: number | null = null 1214 - let recallIntentSimilarity: number | null = null 1215 - for (const row of rows) { 1216 - const similarity = 1 - row.distance 1217 - if (row.category === "chatter") chatterSimilarity = Math.max(chatterSimilarity ?? -Infinity, similarity) 1218 - if (row.category === "recall_intent") { 1219 - recallIntentSimilarity = Math.max(recallIntentSimilarity ?? -Infinity, similarity) 1220 - } 1221 - } 1222 - 1223 - return { 1224 - vector, 1225 - chatterSimilarity, 1226 - recallIntentSimilarity, 1227 - } 1228 - } 1229 - 1230 - function shouldSkipForSemanticChatter(profile: MemorySearchProfile, signal: SemanticQuerySignal | null): boolean { 1231 - if (!signal) return false 1232 - if (profileHasExplicitRecallIntent(profile)) return false 1233 - if (profile.bodyTokens.length > 5) return false 1234 - const chatter = signal.chatterSimilarity ?? 0 1235 - const recallIntent = signal.recallIntentSimilarity ?? 0 1236 - return chatter >= MEMORY_CHATTER_SIMILARITY_THRESHOLD && recallIntent < MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD 1237 - } 1238 - 1239 - function semanticDistancesForQuery(vector: number[], limit: number): Map<number, number> { 1240 - if (!isVecAvailable()) return new Map() 1241 - const rows = getDb() 1242 - .prepare(` 1243 - select rowid as chunkId, distance 1244 - from memory_chunk_vec 1245 - where embedding match ? 1246 - and k = ? 1247 - order by distance 1248 - `) 1249 - .all(vectorParam(vector), limit) as Array<{ chunkId: number; distance: number }> 1250 - return new Map(rows.map((row) => [row.chunkId, row.distance])) 1251 - } 1252 - 1253 - function exactPersonHits(profile: MemorySearchProfile): MemoryHit[] { 1254 - if (profile.bodyPeople.length === 0) return [] 1255 - const db = getDb() 1256 - const hits: MemoryHit[] = [] 1257 - const seen = new Set<number>() 1258 - const stmt = db.prepare(` 1259 - select 1260 - c.id as chunkId, 1261 - d.path as path, 1262 - d.kind as kind, 1263 - d.title as documentTitle, 1264 - c.title as title, 1265 - c.heading_path as headingPath, 1266 - c.chunk_text as text, 1267 - -10.0 as rank 1268 - from memory_chunks c 1269 - join memory_documents d on d.id = c.document_id 1270 - where d.kind = 'people' 1271 - and lower(d.path) like ? 1272 - order by c.chunk_index 1273 - `) 1274 - 1275 - for (const handle of profile.bodyPeople) { 1276 - const rows = stmt.all(`%/people/${handle.toLowerCase()}.md`) as MemoryHit[] 1277 - for (const row of rows) { 1278 - if (seen.has(row.chunkId)) continue 1279 - seen.add(row.chunkId) 1280 - hits.push(row) 1281 - } 1282 - } 1283 - return hits 1284 - } 1285 - 1286 - function scoreMemoryHitWithSemantic(hit: MemoryHit, profile: MemorySearchProfile): number { 1287 - let score = scoreMemoryHit(hit, profile) 1288 - const coreIntent = profileHasCoreIntent(profile) 1289 - 1290 - if (hit.semanticSimilarity !== undefined) { 1291 - score -= hit.semanticSimilarity * 2.5 1292 - if (hit.kind === "core" && !coreIntent && hit.semanticSimilarity < MEMORY_SEMANTIC_STRONG_SIMILARITY) { 1293 - score += 1.25 1294 - } 1295 - } else { 1296 - score += 0.75 1297 - if (hit.kind === "core" && !coreIntent) score += 1.25 1298 - } 1299 - 1300 - if (hit.kind === "core" && !coreIntent) score += 0.75 1301 - return score 1302 - } 1303 - 1304 - async function searchMemory( 1305 - profile: MemorySearchProfile, 1306 - cooldowns: Record<number, number>, 1307 - currentTurn: number, 1308 - limit: number, 1309 - semanticSignal: SemanticQuerySignal | null = null, 1310 - ): Promise<MemoryHit[]> { 1311 - const db = getDb() 1312 - const query = buildSearchQuery(profile) 1313 - if (!query) return [] 1314 - const rows = db 1315 - .prepare(` 1316 - select 1317 - c.id as chunkId, 1318 - d.path as path, 1319 - d.kind as kind, 1320 - d.title as documentTitle, 1321 - c.title as title, 1322 - c.heading_path as headingPath, 1323 - c.chunk_text as text, 1324 - bm25(memory_chunks_fts, 5.0, 2.0, 1.0, 0.5) as rank 1325 - from memory_chunks_fts 1326 - join memory_chunks c on c.id = memory_chunks_fts.rowid 1327 - join memory_documents d on d.id = c.document_id 1328 - where memory_chunks_fts match ? 1329 - order by rank 1330 - limit ? 1331 - `) 1332 - .all(query, Math.max(limit * 8, limit)) as MemoryHit[] 1333 - 1334 - for (const hit of exactPersonHits(profile)) { 1335 - if (rows.some((row) => row.chunkId === hit.chunkId)) continue 1336 - rows.push(hit) 1337 - } 1338 - 1339 - if (semanticSignal) { 1340 - const distances = semanticDistancesForQuery(semanticSignal.vector, Math.max(limit * 16, 64)) 1341 - for (const row of rows) { 1342 - const distance = distances.get(row.chunkId) 1343 - if (distance === undefined) continue 1344 - row.semanticDistance = distance 1345 - row.semanticSimilarity = 1 - distance 1346 - } 1347 - } 1348 - 1349 - const ordered = rows.sort((a, b) => scoreMemoryHitWithSemantic(a, profile) - scoreMemoryHitWithSemantic(b, profile)) 1350 - const pool = 1351 - profile.personQuery && !profile.eventQuery 1352 - ? (() => { 1353 - const structured = ordered.filter((row) => row.kind === "people" || row.kind === "core") 1354 - return structured.length > 0 ? structured : ordered 1355 - })() 1356 - : profile.eventQuery && !profile.personQuery 1357 - ? (() => { 1358 - const journal = ordered.filter((row) => row.kind === "journal") 1359 - return journal.length > 0 ? journal : ordered 1360 - })() 1361 - : ordered 1362 - 1363 - const deduped: MemoryHit[] = [] 1364 - const seenChunkIds = new Set<number>() 1365 - const seenPaths = new Set<string>() 1366 - 1367 - const handles = targetPersonHandles(profile) 1368 - const exactTargetAvailable = 1369 - profile.bodyPeople.length > 0 && pool.some((row) => handles.some((handle) => hitMatchesHandle(row, handle))) 1370 - if (handles.length >= 2) { 1371 - for (const handle of handles) { 1372 - if (deduped.length >= limit) break 1373 - const candidate = pool.find( 1374 - (row) => 1375 - !seenChunkIds.has(row.chunkId) && 1376 - !seenPaths.has(row.path) && 1377 - !isCoolingDown(row, cooldowns, currentTurn) && 1378 - hitMatchesHandle(row, handle), 1379 - ) 1380 - if (!candidate) continue 1381 - seenChunkIds.add(candidate.chunkId) 1382 - seenPaths.add(candidate.path) 1383 - deduped.push(candidate) 1384 - } 1385 - } 1386 - 1387 - for (const row of pool) { 1388 - if (deduped.length >= limit) break 1389 - if (seenChunkIds.has(row.chunkId)) continue 1390 - if (isCoolingDown(row, cooldowns, currentTurn)) continue 1391 - const exactTargetMatch = profile.bodyPeople.length > 0 && handles.some((handle) => hitMatchesHandle(row, handle)) 1392 - if (exactTargetAvailable && !exactTargetMatch && row.kind !== "journal") { 1393 - continue 1394 - } 1395 - if (profile.bodyPeople.length > 0 && !profile.bodyPeople.some((handle) => hitMentionsHandle(row, handle))) { 1396 - continue 1397 - } 1398 - if ( 1399 - semanticSignal && 1400 - row.kind === "core" && 1401 - !profileHasCoreIntent(profile) && 1402 - (row.semanticSimilarity ?? 0) < MEMORY_SEMANTIC_MIN_SIMILARITY 1403 - ) { 1404 - continue 1405 - } 1406 - seenChunkIds.add(row.chunkId) 1407 - deduped.push(row) 1408 - } 1409 - 1410 - return deduped 1411 - } 1412 - 1413 - function formatMemorySource(hit: MemoryHit): string { 1414 - const relativePath = path.relative(HOME_DIR, hit.path) 1415 - const pieces = [`[${hit.kind}]`, hit.documentTitle] 1416 - if (hit.headingPath && hit.headingPath !== hit.documentTitle) pieces.push(hit.headingPath) 1417 - pieces.push(relativePath) 1418 - return pieces.join(" / ") 1419 - } 1420 - 1421 - function toMemorySearchResult(hit: MemoryHit): MemorySearchResult { 1422 - return { 1423 - chunkId: hit.chunkId, 1424 - kind: hit.kind, 1425 - path: hit.path, 1426 - source: formatMemorySource(hit), 1427 - title: hit.title, 1428 - headingPath: hit.headingPath, 1429 - preview: trimForPrompt(normalizeText(hit.text), 280), 1430 - } 1431 - } 1432 - 1433 - function buildMemoryRecallMessage(hits: MemoryHit[], maxChars: number): string { 1434 - const lines = [MEMORY_RECALL_HEADER, MEMORY_RECALL_NOTE, ""] 1435 - let usedChars = lines.join("\n").length 1436 - 1437 - for (const hit of hits) { 1438 - const source = formatMemorySource(hit) 1439 - const remaining = Math.max(120, maxChars - usedChars - source.length - 10) 1440 - const body = trimForPrompt(normalizeText(hit.text), Math.min(280, remaining)) 1441 - const block = `- ${source}\n ${body}` 1442 - 1443 - if (usedChars + block.length > maxChars && lines.length > 3) break 1444 - lines.push(block) 1445 - usedChars += block.length + 1 1446 - } 1447 - 1448 - return lines.join("\n").trim() 1449 - } 1450 - 1451 - export async function buildCompletionMessages( 1452 - conversation: Message[], 1453 - cooldowns: Record<number, number>, 1454 - currentTurn: number, 1455 - ): Promise<{ messages: Message[]; recalledChunkIds: number[] }> { 1456 - const memoryQuerySource = latestMemoryRecallQuery(conversation) 1457 - if (!memoryQuerySource) return { messages: conversation, recalledChunkIds: [] } 1458 - const queryParts = memoryQueryForUserMessage(memoryQuerySource) 1459 - const memoryQuery = memoryQueryToString(queryParts) 1460 - 1461 - await syncMemoryIndex() 1462 - 1463 - const profile = await buildSearchProfile(queryParts) 1464 - if (profile.tokens.length === 0) return { messages: conversation, recalledChunkIds: [] } 1465 - 1466 - if ((profile.sender || profile.bodyPeople.length > 0) && !profile.bodyInformative) { 1467 - console.log( 1468 - `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} sender=${profile.sender ?? "-"} reason=trivial-body`, 1469 - ) 1470 - return { messages: conversation, recalledChunkIds: [] } 1471 - } 1472 - 1473 - let semanticSignal: SemanticQuerySignal | null = null 1474 - try { 1475 - semanticSignal = await semanticQuerySignal(memoryQuery) 1476 - } catch (err: any) { 1477 - console.warn(`[memory] semantic query failed: ${err?.message ?? String(err)}`) 1478 - } 1479 - 1480 - if (shouldSkipForSemanticChatter(profile, semanticSignal)) { 1481 - console.log( 1482 - `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} sender=${profile.sender ?? "-"} reason=semantic-chatter chatter=${semanticSignal?.chatterSimilarity?.toFixed(3) ?? "-"} recallIntent=${semanticSignal?.recallIntentSimilarity?.toFixed(3) ?? "-"}`, 1483 - ) 1484 - return { messages: conversation, recalledChunkIds: [] } 1485 - } 1486 - 1487 - const personCount = allPersonHandles(profile).length 1488 - const recallLimit = Math.min( 1489 - MEMORY_RECALL_MAX_CHUNKS_HARD_CAP, 1490 - personCount >= 2 ? MEMORY_RECALL_MAX_CHUNKS + (personCount - 1) : MEMORY_RECALL_MAX_CHUNKS, 1491 - ) 1492 - const hits = await searchMemory(profile, cooldowns, currentTurn, recallLimit, semanticSignal) 1493 - const aliasInfo = profile.senderAliases.length > 0 ? ` aliases=${profile.senderAliases.join(",")}` : "" 1494 - const peopleInfo = profile.bodyPeople.length > 0 ? ` people=${profile.bodyPeople.join(",")}` : "" 1495 - const debugTag = `sender=${profile.sender ?? "-"}${aliasInfo}${peopleInfo} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}` 1496 - if (hits.length === 0) { 1497 - console.log( 1498 - `[memory] no-hits query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}`, 1499 - ) 1500 - return { messages: conversation, recalledChunkIds: [] } 1501 - } 1502 - if (!shouldInjectHits(hits, profile)) { 1503 - console.log( 1504 - `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag} reason=weak-match`, 1505 - ) 1506 - return { messages: conversation, recalledChunkIds: [] } 1507 - } 1508 - 1509 - const extraPersons = Math.max(0, personCount - 1) 1510 - const recallChars = MEMORY_RECALL_MAX_CHARS + extraPersons * MEMORY_RECALL_PER_EXTRA_PERSON_CHARS 1511 - const recallContent = buildMemoryRecallMessage(hits, recallChars) 1512 - console.log( 1513 - `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}\n${recallContent}`, 1514 - ) 1515 - 1516 - recordMetric({ 1517 - type: "memory", 1518 - query: memoryQuery, 1519 - results: hits.map(toMemorySearchResult), 1520 - }) 1521 - 1522 - const recallMessage: Message = { 1523 - role: "user", 1524 - content: recallContent, 1525 - } 1526 - 1527 - return { 1528 - messages: [...conversation, recallMessage], 1529 - recalledChunkIds: hits.map((hit) => hit.chunkId), 1530 - } 1531 - } 1532 - 1533 - export function rememberRecalledMemoryChunks( 1534 - cooldowns: Record<number, number>, 1535 - injectedChunkIds: number[], 1536 - currentTurn: number, 1537 - ): Record<number, number> { 1538 - const next = { ...cooldowns } 1539 - for (const chunkId of injectedChunkIds) next[chunkId] = currentTurn 1540 - 1541 - for (const [chunkId, turn] of Object.entries(next)) { 1542 - if (currentTurn - turn >= MEMORY_RECALL_COOLDOWN_TURNS * 2) delete next[Number(chunkId)] 1543 - } 1544 - 1545 - return next 1546 - } 1547 - 1548 - export const __memoryTest = { 1549 - latestMemoryRecallQuery, 1550 - memoryQueryForUserMessage, 1551 - memoryQueryToString, 1552 - normalizeBodyText, 1553 - searchTokens, 1554 - buildSearchProfile, 1555 - resolveAliases, 1556 - normalizeHandle, 1557 - } 1558 - 1559 - export async function searchMemories(rawQuery: string, limit = 5): Promise<MemorySearchResult[]> { 1560 - await syncMemoryIndex() 1561 - 1562 - const profile = await buildSearchProfile({ sender: null, source: null, body: rawQuery }) 1563 - if (profile.tokens.length === 0) return [] 1564 - 1565 - let semanticSignal: SemanticQuerySignal | null = null 1566 - try { 1567 - semanticSignal = await semanticQuerySignal(rawQuery) 1568 - } catch { 1569 - semanticSignal = null 1570 - } 1571 - 1572 - const results = (await searchMemory( 1573 - profile, 1574 - {}, 1575 - Number.POSITIVE_INFINITY, 1576 - Math.max(1, Math.min(limit, 10)), 1577 - semanticSignal, 1578 - )).map(toMemorySearchResult) 1579 - recordMetric({ 1580 - type: "memory", 1581 - query: rawQuery, 1582 - results, 1583 - }) 1584 - return results 1585 - }
+138
src/memory/aliases.ts
··· 1 + /** 2 + * Memory alias system — handle resolution, loading, and CRUD. 3 + * 4 + * @module memory/aliases 5 + */ 6 + 7 + import fs from "fs/promises" 8 + import { ALIASES_FILE, MEMORIES_DIR, type AliasMap } from "./shared" 9 + import { normalizeHandle } from "./shared" 10 + 11 + export { normalizeHandle } 12 + 13 + // ── caching ──────────────────────────────────────────────────────────── 14 + 15 + let aliasCache: { mtimeMs: number; map: AliasMap } | null = null 16 + 17 + /** 18 + * Loads and caches the alias map from disk. 19 + * 20 + * @returns Alias map keyed by normalized handle. 21 + */ 22 + export async function loadAliasMap(): Promise<AliasMap> { 23 + try { 24 + const stat = await fs.stat(ALIASES_FILE) 25 + if (aliasCache && aliasCache.mtimeMs === stat.mtimeMs) return aliasCache.map 26 + const raw = await fs.readFile(ALIASES_FILE, "utf-8") 27 + const parsed = JSON.parse(raw) as unknown 28 + const map: AliasMap = {} 29 + if (parsed && typeof parsed === "object") { 30 + for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { 31 + const handle = normalizeHandle(key) 32 + if (!handle) continue 33 + const list = Array.isArray(value) ? value : [value] 34 + const aliases = list 35 + .map((v) => (typeof v === "string" ? normalizeHandle(v) : "")) 36 + .filter((v) => v && v !== handle) 37 + if (aliases.length > 0) map[handle] = Array.from(new Set(aliases)) 38 + } 39 + } 40 + aliasCache = { mtimeMs: stat.mtimeMs, map } 41 + return map 42 + } catch { 43 + aliasCache = { mtimeMs: 0, map: {} } 44 + return {} 45 + } 46 + } 47 + 48 + /** 49 + * Persists the alias map to disk and invalidates the cache. 50 + * 51 + * @param map - Alias map to write. 52 + */ 53 + async function writeAliasMap(map: AliasMap): Promise<void> { 54 + await fs.mkdir(MEMORIES_DIR, { recursive: true }) 55 + const sorted: AliasMap = {} 56 + for (const key of Object.keys(map).sort()) sorted[key] = [...map[key]!].sort() 57 + await fs.writeFile(ALIASES_FILE, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8") 58 + aliasCache = null 59 + } 60 + 61 + /** 62 + * Resolves all transitive aliases for a handle via BFS traversal. 63 + * 64 + * @param handle - Starting handle (or `null`). 65 + * @param map - Alias map to traverse. 66 + * @returns All reachable alias handles (not including the start). 67 + */ 68 + export function resolveAliases(handle: string | null, map: AliasMap): string[] { 69 + if (!handle) return [] 70 + const seen = new Set<string>([handle]) 71 + const out: string[] = [] 72 + const queue = [handle] 73 + while (queue.length > 0) { 74 + const current = queue.shift()! 75 + const next = map[current] ?? [] 76 + for (const alias of next) { 77 + if (seen.has(alias)) continue 78 + seen.add(alias) 79 + out.push(alias) 80 + queue.push(alias) 81 + } 82 + } 83 + return out 84 + } 85 + 86 + // ── public CRUD ──────────────────────────────────────────────────────── 87 + 88 + /** 89 + * Lists all current handle aliases. 90 + * 91 + * @returns Full alias map. 92 + */ 93 + export async function listAliases(): Promise<AliasMap> { 94 + return loadAliasMap() 95 + } 96 + 97 + /** 98 + * Links a handle to a canonical name. 99 + * 100 + * @param handle - Handle to alias. 101 + * @param canonical - Canonical name to link. 102 + * @returns Updated alias map. 103 + */ 104 + export async function setAlias(handle: string, canonical: string): Promise<AliasMap> { 105 + const h = normalizeHandle(handle) 106 + const c = normalizeHandle(canonical) 107 + if (!h || !c) throw new Error("alias handle and canonical must be non-empty") 108 + const map = await loadAliasMap() 109 + if (h === c) return map 110 + const existing = new Set(map[h] ?? []) 111 + existing.add(c) 112 + map[h] = Array.from(existing) 113 + await writeAliasMap(map) 114 + return map 115 + } 116 + 117 + /** 118 + * Removes one or all aliases for a handle. 119 + * 120 + * @param handle - Handle to modify. 121 + * @param canonical - Specific alias to remove (omit to clear all). 122 + * @returns Updated alias map. 123 + */ 124 + export async function removeAlias(handle: string, canonical?: string): Promise<AliasMap> { 125 + const h = normalizeHandle(handle) 126 + if (!h) throw new Error("alias handle must be non-empty") 127 + const map = await loadAliasMap() 128 + if (!map[h]) return map 129 + if (canonical) { 130 + const c = normalizeHandle(canonical) 131 + map[h] = map[h]!.filter((entry) => entry !== c) 132 + if (map[h]!.length === 0) delete map[h] 133 + } else { 134 + delete map[h] 135 + } 136 + await writeAliasMap(map) 137 + return map 138 + }
+202
src/memory/discord-query.ts
··· 1 + /** 2 + * Discord-specific memory query parsing. 3 + * 4 + * Extracts structured sender/source/body parts from formatted Discord 5 + * trigger messages so the memory search pipeline can use them as signals. 6 + * 7 + * @module memory/discord-query 8 + */ 9 + 10 + import { basenameWithoutExt, normalizeHandle } from "./shared" 11 + import { getDb } from "../db" 12 + 13 + export type MemoryQueryParts = { 14 + sender: string | null 15 + source: string | null 16 + body: string 17 + } 18 + 19 + // ── wake envelope stripping ──────────────────────────────────────────── 20 + 21 + const WAKE_ENVELOPE_PATTERN = /^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi 22 + 23 + /** 24 + * Strips leading `[wake]` / `[incoming]` / `[harness restarted]` envelopes from raw message content. 25 + * 26 + * @param raw - Raw message content. 27 + * @returns Content without the leading envelope. 28 + */ 29 + export function stripWakeEnvelope(raw: string): string { 30 + return raw.replace(WAKE_ENVELOPE_PATTERN, "").trim() 31 + } 32 + 33 + // ── discord channel label ────────────────────────────────────────────── 34 + 35 + /** 36 + * Resolves a human-readable channel label from the discord channels table. 37 + * 38 + * @param channelId - Discord channel id. 39 + * @param fallbackContext - Raw context string from the trigger message. 40 + * @param isDm - Whether the message is a DM. 41 + * @returns Short label like `"DM"`, `"guild/#channel"`, or `"#channel"`. 42 + */ 43 + export function discordChannelLabel(channelId: string | null, fallbackContext: string | null, isDm: boolean): string { 44 + if (isDm) return "DM" 45 + 46 + const fallback = fallbackContext 47 + ?.replace(/^context:\s*/i, "") 48 + .replace(/\s*\(\d+\)\s*$/, "") 49 + .trim() 50 + 51 + if (channelId) { 52 + try { 53 + const row = getDb() 54 + .prepare("select guild_id, guild_name, channel_name, is_dm from discord_channels where channel_id = ?") 55 + .get(channelId) as 56 + | { 57 + guild_id: string | null 58 + guild_name: string | null 59 + channel_name: string | null 60 + is_dm: number 61 + } 62 + | undefined 63 + 64 + if (row?.is_dm) return "DM" 65 + if (row) { 66 + const guild = row.guild_name ?? row.guild_id 67 + const channel = row.channel_name ?? channelId 68 + if (guild && channel) return `${guild}/#${channel}` 69 + if (channel) return `#${channel}` 70 + } 71 + } catch { 72 + // If the main db is unavailable in tests or scripts, keep the parsed context. 73 + } 74 + } 75 + 76 + if (fallback) return fallback 77 + return channelId ? `#${channelId}` : "channel" 78 + } 79 + 80 + // ── single discord DM/channel message ────────────────────────────────── 81 + 82 + /** 83 + * Parses a single discord DM or channel trigger message into structured parts. 84 + * 85 + * @param raw - Raw trigger message content. 86 + * @returns Parsed parts, or `null` when the message is not a discord trigger. 87 + */ 88 + export function conciseDiscordMemoryQuery(raw: string): MemoryQueryParts | null { 89 + const withoutWakeEnvelope = stripWakeEnvelope(raw) 90 + if (!/\[discord\/(?:dm|channel)\]/i.test(withoutWakeEnvelope)) return null 91 + 92 + const blocks = withoutWakeEnvelope 93 + .split(/\n\s*\n/g) 94 + .map((block) => block.trim()) 95 + .filter(Boolean) 96 + const headerBlock = blocks[0] ?? withoutWakeEnvelope 97 + const message = blocks.length > 1 ? blocks.slice(1).join("\n\n").trim() : "" 98 + 99 + const lines = headerBlock.split("\n").map((line) => line.trim()).filter(Boolean) 100 + const discordLine = lines.find((line) => /^\[discord\/(?:dm|channel)\]/i.test(line)) ?? "" 101 + const contextLine = lines.find((line) => /^context:\s*/i.test(line)) ?? null 102 + const isDm = /\[discord\/dm\]/i.test(discordLine) 103 + const author = discordLine.match(/@(\S+)/)?.[1] ?? null 104 + const context = contextLine?.replace(/^context:\s*/i, "").trim() ?? "" 105 + const dmChannelId = context.match(/^DM\s+(\d+)/i)?.[1] ?? null 106 + const namedChannelId = context.match(/\((\d+)\)\s*$/)?.[1] ?? null 107 + const channelId = dmChannelId ?? namedChannelId 108 + const location = discordChannelLabel(channelId, contextLine, isDm) 109 + 110 + if (!author && !location && !message) return null 111 + return { 112 + sender: author ? normalizeHandle(author) : null, 113 + source: location || null, 114 + body: message, 115 + } 116 + } 117 + 118 + // ── discord batch digest ─────────────────────────────────────────────── 119 + 120 + function extractBulletSection(raw: string, label: string): string[] { 121 + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 122 + const match = raw.match(new RegExp(`(?:^|\\n)${escapedLabel}:\\n([\\s\\S]*?)(?:\\n\\n[^\\n:]+:|$)`, "i")) 123 + if (!match) return [] 124 + 125 + return match[1] 126 + .split("\n") 127 + .map((line) => line.trim()) 128 + .filter((line) => line.startsWith("- ")) 129 + .map((line) => line.slice(2).trim()) 130 + .filter((line) => line && line !== "(none)") 131 + } 132 + 133 + /** 134 + * Parses a discord batch digest trigger message into structured parts. 135 + * 136 + * @param raw - Raw trigger message content. 137 + * @returns Parsed parts from the pending preview entries, or `null`. 138 + */ 139 + export function conciseDiscordBatchMemoryQuery(raw: string): MemoryQueryParts | null { 140 + const withoutWakeEnvelope = stripWakeEnvelope(raw) 141 + if (!/\[discord batch\]/i.test(withoutWakeEnvelope)) return null 142 + 143 + const pending = extractBulletSection(withoutWakeEnvelope, "pending preview") 144 + const selected = pending.filter((entry) => !/^\(none\)$/i.test(entry)).slice(-3) 145 + if (selected.length === 0) return null 146 + 147 + const senders: string[] = [] 148 + const sources: string[] = [] 149 + const bodies: string[] = [] 150 + const entryPattern = /(?:^|\s)\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*(.*)$/i 151 + for (const entry of selected) { 152 + const match = entry.match(entryPattern) 153 + if (!match) { 154 + bodies.push(entry) 155 + continue 156 + } 157 + const [, location, author, body] = match 158 + if (author) senders.push(normalizeHandle(author)) 159 + if (location) sources.push(location.trim()) 160 + if (body) bodies.push(body.trim()) 161 + } 162 + 163 + const lastSender = senders.length > 0 ? senders[senders.length - 1]! : null 164 + const lastSource = sources.length > 0 ? sources[sources.length - 1]! : null 165 + const body = bodies.filter(Boolean).join("\n").trim() 166 + 167 + if (!lastSender && !lastSource && !body) return null 168 + return { sender: lastSender, source: lastSource, body } 169 + } 170 + 171 + // ── unified query parser ─────────────────────────────────────────────── 172 + 173 + /** 174 + * Parses a raw trigger message into structured memory query parts. 175 + * 176 + * Tries discord-specific parsers first, then falls back to treating 177 + * the full content (minus wake envelope) as the query body. 178 + * 179 + * @param raw - Raw trigger message content. 180 + * @returns Structured query parts. 181 + */ 182 + export function memoryQueryForUserMessage(raw: string): MemoryQueryParts { 183 + return ( 184 + conciseDiscordMemoryQuery(raw) ?? 185 + conciseDiscordBatchMemoryQuery(raw) ?? 186 + { sender: null, source: null, body: stripWakeEnvelope(raw) } 187 + ) 188 + } 189 + 190 + /** 191 + * Serializes query parts back into a search string. 192 + * 193 + * @param parts - Structured query parts. 194 + * @returns Concatenated sender + body string. 195 + */ 196 + export function memoryQueryToString(parts: MemoryQueryParts): string { 197 + const pieces = [ 198 + parts.sender ? `@${parts.sender}` : null, 199 + parts.body || null, 200 + ].filter((value): value is string => Boolean(value && value.trim())) 201 + return pieces.join("\n") 202 + }
+271
src/memory/index.ts
··· 1 + /** 2 + * Memory system public API. 3 + * 4 + * Re-exports the surface used by the rest of the codebase: 5 + * - `buildCompletionMessages` — recall pipeline for the runner loop 6 + * - `searchMemories` — explicit tool-triggered search 7 + * - `rememberRecalledMemoryChunks` — cooldown tracking 8 + * - `listAliases`, `setAlias`, `removeAlias` — alias CRUD 9 + * - `__memoryTest` — test-only internals 10 + * 11 + * @module memory 12 + */ 13 + 14 + import type { Message } from "../types" 15 + import { recordMetric } from "../metrics" 16 + import { syncMemoryIndex } from "./sync" 17 + import { 18 + MEMORY_RECALL_COOLDOWN_TURNS, 19 + MEMORY_RECALL_MAX_CHARS, 20 + MEMORY_RECALL_MAX_CHUNKS, 21 + MEMORY_RECALL_MAX_CHUNKS_HARD_CAP, 22 + MEMORY_RECALL_PER_EXTRA_PERSON_CHARS, 23 + normalizeText, 24 + SCHEDULED_HEARTBEAT_CONTENT, 25 + trimForPrompt, 26 + type MemorySearchResult, 27 + } from "./shared" 28 + import { loadAliasMap, resolveAliases, listAliases, setAlias, removeAlias, normalizeHandle } from "./aliases" 29 + import { 30 + buildSearchProfile, 31 + semanticQuerySignal, 32 + shouldSkipForSemanticChatter, 33 + searchMemory, 34 + isRelevant, 35 + buildMemoryRecallMessage, 36 + toMemorySearchResult, 37 + } from "./search" 38 + import { 39 + memoryQueryForUserMessage, 40 + memoryQueryToString, 41 + } from "./discord-query" 42 + 43 + export { listAliases, setAlias, removeAlias, normalizeHandle } from "./aliases" 44 + export { resolveAliases } from "./aliases" 45 + export { syncMemoryIndex } from "./sync" 46 + export { memoryQueryForUserMessage, stripWakeEnvelope } from "./discord-query" 47 + export { normalizeBodyText, tokensFromText as searchTokens } from "./shared" 48 + export { buildSearchProfile } from "./search" 49 + export type { AliasMap, MemorySearchResult } from "./shared" 50 + 51 + // ── recall pipeline ──────────────────────────────────────────────────── 52 + 53 + function isMemoryRecallSkippedMessage(content: string): boolean { 54 + if (content.startsWith("[memory recall")) return true 55 + if (content.startsWith("[system]")) return true 56 + if (content.includes("scan snapshot:") && !content.includes("[discord batch]")) return true 57 + if (/\[discord batch\]/i.test(content) && conciseDiscordBatchMemoryQuery(content) === null) return true 58 + return false 59 + } 60 + 61 + function latestMemoryRecallQuery(conversation: Message[]): string | null { 62 + for (let i = conversation.length - 1; i >= 0; i -= 1) { 63 + const message = conversation[i] 64 + if (!message || message.role !== "user") continue 65 + if (typeof message.content !== "string") continue 66 + 67 + const content = message.content.trim() 68 + if (!content || isMemoryRecallSkippedMessage(content)) continue 69 + if (content === SCHEDULED_HEARTBEAT_CONTENT) continue 70 + return content 71 + } 72 + return null 73 + } 74 + 75 + // Need this import for isMemoryRecallSkippedMessage 76 + import { conciseDiscordBatchMemoryQuery } from "./discord-query" 77 + 78 + /** 79 + * Runs the memory recall pipeline: extracts a query, searches, gates, and formats. 80 + * 81 + * @param conversation - Current conversation messages. 82 + * @param cooldowns - Chunk id → last-recalled turn mapping. 83 + * @param currentTurn - Current conversation turn number. 84 + * @returns Updated messages (with recall injected) and recalled chunk ids. 85 + */ 86 + export async function buildCompletionMessages( 87 + conversation: Message[], 88 + cooldowns: Record<number, number>, 89 + currentTurn: number, 90 + ): Promise<{ messages: Message[]; recalledChunkIds: number[] }> { 91 + const memoryQuerySource = latestMemoryRecallQuery(conversation) 92 + if (!memoryQuerySource) return { messages: conversation, recalledChunkIds: [] } 93 + const queryParts = memoryQueryForUserMessage(memoryQuerySource) 94 + const memoryQuery = memoryQueryToString(queryParts) 95 + 96 + await syncMemoryIndex() 97 + 98 + const profile = await buildSearchProfile(queryParts) 99 + if (profile.tokens.length === 0) return { messages: conversation, recalledChunkIds: [] } 100 + 101 + if ((profile.sender || profile.bodyPeople.length > 0) && !profile.bodyInformative) { 102 + console.log( 103 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} sender=${profile.sender ?? "-"} reason=trivial-body`, 104 + ) 105 + return { messages: conversation, recalledChunkIds: [] } 106 + } 107 + 108 + let semanticSignal: import("./shared").SemanticQuerySignal | null = null 109 + try { 110 + semanticSignal = await semanticQuerySignal(memoryQuery) 111 + } catch (err: any) { 112 + console.warn(`[memory] semantic query failed: ${err?.message ?? String(err)}`) 113 + } 114 + 115 + if (shouldSkipForSemanticChatter(profile, semanticSignal)) { 116 + console.log( 117 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} sender=${profile.sender ?? "-"} reason=semantic-chatter chatter=${semanticSignal?.chatterSimilarity?.toFixed(3) ?? "-"} recallIntent=${semanticSignal?.recallIntentSimilarity?.toFixed(3) ?? "-"}`, 118 + ) 119 + return { messages: conversation, recalledChunkIds: [] } 120 + } 121 + 122 + const personCount = [...(profile.sender ? [profile.sender] : []), ...profile.bodyPeople].length 123 + const recallLimit = Math.min( 124 + MEMORY_RECALL_MAX_CHUNKS_HARD_CAP, 125 + personCount >= 2 ? MEMORY_RECALL_MAX_CHUNKS + (personCount - 1) : MEMORY_RECALL_MAX_CHUNKS, 126 + ) 127 + const hits = await searchMemory(profile, cooldowns, currentTurn, recallLimit, semanticSignal) 128 + const aliasInfo = profile.senderAliases.length > 0 ? ` aliases=${profile.senderAliases.join(",")}` : "" 129 + const peopleInfo = profile.bodyPeople.length > 0 ? ` people=${profile.bodyPeople.join(",")}` : "" 130 + const debugTag = `sender=${profile.sender ?? "-"}${aliasInfo}${peopleInfo} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}` 131 + if (hits.length === 0) { 132 + console.log( 133 + `[memory] no-hits query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}`, 134 + ) 135 + return { messages: conversation, recalledChunkIds: [] } 136 + } 137 + if (!isRelevant(hits, profile)) { 138 + console.log( 139 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag} reason=weak-match`, 140 + ) 141 + return { messages: conversation, recalledChunkIds: [] } 142 + } 143 + 144 + const extraPersons = Math.max(0, personCount - 1) 145 + const recallChars = MEMORY_RECALL_MAX_CHARS + extraPersons * MEMORY_RECALL_PER_EXTRA_PERSON_CHARS 146 + const recallContent = buildMemoryRecallMessage(hits, recallChars) 147 + console.log( 148 + `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} ${debugTag}\n${recallContent}`, 149 + ) 150 + 151 + recordMetric({ 152 + type: "memory", 153 + query: memoryQuery, 154 + results: hits.map(toMemorySearchResult), 155 + }) 156 + 157 + const recallMessage: Message = { 158 + role: "user", 159 + content: recallContent, 160 + } 161 + 162 + return { 163 + messages: [...conversation, recallMessage], 164 + recalledChunkIds: hits.map((hit) => hit.chunkId), 165 + } 166 + } 167 + 168 + /** 169 + * Updates cooldown tracking with newly recalled chunk ids and prunes stale entries. 170 + * 171 + * @param cooldowns - Current cooldown map. 172 + * @param injectedChunkIds - Chunk ids just injected. 173 + * @param currentTurn - Current conversation turn number. 174 + * @returns Updated cooldown map. 175 + */ 176 + export function rememberRecalledMemoryChunks( 177 + cooldowns: Record<number, number>, 178 + injectedChunkIds: number[], 179 + currentTurn: number, 180 + ): Record<number, number> { 181 + const next = { ...cooldowns } 182 + for (const chunkId of injectedChunkIds) next[chunkId] = currentTurn 183 + 184 + for (const [chunkId, turn] of Object.entries(next)) { 185 + if (currentTurn - turn >= MEMORY_RECALL_COOLDOWN_TURNS * 2) delete next[Number(chunkId)] 186 + } 187 + 188 + return next 189 + } 190 + 191 + /** 192 + * Explicit tool-triggered memory search. 193 + * 194 + * @param rawQuery - Search query string. 195 + * @param limit - Maximum results (default 5, max 10). 196 + * @returns Search results. 197 + */ 198 + export async function searchMemories(rawQuery: string, limit = 5): Promise<MemorySearchResult[]> { 199 + await syncMemoryIndex() 200 + 201 + const profile = await buildSearchProfile({ sender: null, source: null, body: rawQuery }) 202 + if (profile.tokens.length === 0) return [] 203 + 204 + let semanticSignal: import("./shared").SemanticQuerySignal | null = null 205 + try { 206 + semanticSignal = await semanticQuerySignal(rawQuery) 207 + } catch { 208 + semanticSignal = null 209 + } 210 + 211 + const results = (await searchMemory( 212 + profile, 213 + {}, 214 + Number.POSITIVE_INFINITY, 215 + Math.max(1, Math.min(limit, 10)), 216 + semanticSignal, 217 + )).map(toMemorySearchResult) 218 + recordMetric({ 219 + type: "memory", 220 + query: rawQuery, 221 + results, 222 + }) 223 + return results 224 + } 225 + 226 + // ── test exports ─────────────────────────────────────────────────────── 227 + 228 + export const __memoryTest = { 229 + latestMemoryRecallQuery, 230 + memoryQueryForUserMessage, 231 + memoryQueryToString, 232 + normalizeBodyText: (raw: string) => raw 233 + .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 234 + .replace(/\b\d{6,}\b/g, " ") 235 + .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 236 + .toLowerCase() 237 + .trim(), 238 + searchTokens: (raw: string) => { 239 + const clean = raw 240 + .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 241 + .replace(/\b\d{6,}\b/g, " ") 242 + .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 243 + .toLowerCase() 244 + .trim() 245 + const tokens = clean 246 + .split(/\s+/) 247 + .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 248 + .filter((token) => token.length >= 2 || /\d{2,}/.test(token)) 249 + .filter((token) => !(new Set([ 250 + "a", "an", "and", "are", "as", "at", "be", "been", "but", "by", 251 + "for", "from", "had", "has", "have", "he", "her", "hers", "him", 252 + "his", "i", "if", "in", "into", "is", "it", "its", "me", "my", 253 + "of", "on", "or", "our", "she", "that", "the", "their", "them", 254 + "there", "they", "this", "to", "up", "us", "was", "we", "were", 255 + "with", "you", "your", 256 + ]).has(token))) 257 + 258 + const unique: string[] = [] 259 + const seen = new Set<string>() 260 + for (const token of tokens) { 261 + if (seen.has(token)) continue 262 + seen.add(token) 263 + unique.push(token) 264 + if (unique.length >= 12) break 265 + } 266 + return unique 267 + }, 268 + buildSearchProfile, 269 + resolveAliases, 270 + normalizeHandle, 271 + }
+693
src/memory/search.ts
··· 1 + /** 2 + * Memory search — profile building, FTS query, scoring, dedup, and gating. 3 + * 4 + * The scoring pipeline is consolidated into two core functions: 5 + * - `scoreHit` computes a single numeric rank combining BM25, kind boosting, 6 + * person matching, token overlap, and semantic similarity. 7 + * - `isRelevant` decides whether the top hit is strong enough to inject. 8 + * 9 + * @module memory/search 10 + */ 11 + 12 + import fs from "fs/promises" 13 + import path from "path" 14 + import { getDb, isVecAvailable, MEMORY_EMBEDDING_DIMENSIONS } from "../db" 15 + import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL, embeddingsConfigured, embedTexts } from "../embeddings" 16 + import { 17 + basenameWithoutExt, 18 + BODY_INFORMATIVE_BM25_THRESHOLD, 19 + MEMORY_CHATTER_SIMILARITY_THRESHOLD, 20 + MEMORY_RECALL_COOLDOWN_TURNS, 21 + MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD, 22 + MEMORY_SEMANTIC_MIN_SIMILARITY, 23 + MEMORY_SEMANTIC_STRONG_SIMILARITY, 24 + MEMORY_QUERY_TOKEN_LIMIT, 25 + normalizeHandle, 26 + normalizeText, 27 + PEOPLE_DIR, 28 + tokensFromText, 29 + trimForPrompt, 30 + type MemoryHit, 31 + type MemoryKind, 32 + type MemorySearchProfile, 33 + type SemanticQuerySignal, 34 + } from "./shared" 35 + import { HOME_DIR } from "../container/config" 36 + import { loadAliasMap, resolveAliases } from "./aliases" 37 + 38 + // ── handle helpers ───────────────────────────────────────────────────── 39 + 40 + /** All handles associated with the sender (sender + sender aliases). */ 41 + function senderHandles(profile: MemorySearchProfile): string[] { 42 + const out: string[] = [] 43 + if (profile.sender) out.push(profile.sender) 44 + for (const alias of profile.senderAliases) out.push(alias) 45 + return out 46 + } 47 + 48 + /** All person handles: sender + aliases + body-mentioned people. */ 49 + function allPersonHandles(profile: MemorySearchProfile): string[] { 50 + const seen = new Set<string>() 51 + const out: string[] = [] 52 + for (const handle of [...senderHandles(profile), ...profile.bodyPeople]) { 53 + if (!handle || seen.has(handle)) continue 54 + seen.add(handle) 55 + out.push(handle) 56 + } 57 + return out 58 + } 59 + 60 + /** Target person handles for exact matching — prefers body mentions over sender. */ 61 + function targetPersonHandles(profile: MemorySearchProfile): string[] { 62 + return profile.bodyPeople.length > 0 ? profile.bodyPeople : allPersonHandles(profile) 63 + } 64 + 65 + // ── haystack / matching ──────────────────────────────────────────────── 66 + 67 + /** Lowercase text blob of all searchable fields for a hit. */ 68 + function hitHaystack(hit: MemoryHit): string { 69 + return `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)} ${hit.text}`.toLowerCase() 70 + } 71 + 72 + /** Title + path only (lighter match for handle-in-filename detection). */ 73 + function hitTitlePath(hit: MemoryHit): string { 74 + return `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 75 + } 76 + 77 + /** Whether a handle appears in the hit's title/path/filename. */ 78 + function handleInTitlePath(hit: MemoryHit, handle: string): boolean { 79 + return hitTitlePath(hit).includes(handle) || hit.path.toLowerCase().includes(`/${handle}.md`) 80 + } 81 + 82 + // ── intent classification ────────────────────────────────────────────── 83 + 84 + function profileHasExplicitRecallIntent(profile: MemorySearchProfile): boolean { 85 + if (profile.bodyPeople.length > 0) return true 86 + if (profile.eventQuery) return true 87 + return /\b(who is|who's|tell me about|remember|recall|what happened|what do i know|context)\b/.test(profile.normalized) 88 + } 89 + 90 + function profileHasCoreIntent(profile: MemorySearchProfile): boolean { 91 + return /\b(core|identity|system|environment|lesson|rule|instruction|workflow|tool|config|memory)\b/.test(profile.normalized) 92 + } 93 + 94 + // ── scoring ──────────────────────────────────────────────────────────── 95 + 96 + /** 97 + * Computes a single numeric score for a memory hit, combining: 98 + * - BM25 base rank 99 + * - kind-based boosting (people/journal/core) 100 + * - person handle matching (title/path bonus, text mention bonus, target person bonus) 101 + * - body token overlap with title 102 + * - semantic similarity (when available) 103 + * 104 + * Lower scores are better. 105 + * 106 + * @param hit - Candidate memory hit. 107 + * @param profile - Search profile with query signals. 108 + * @param semanticSimilarity - Optional semantic similarity score (0–1). 109 + * @returns Numeric score (lower = better match). 110 + */ 111 + function scoreHit(hit: MemoryHit, profile: MemorySearchProfile, semanticSimilarity?: number): number { 112 + let score = hit.rank 113 + const coreIntent = profileHasCoreIntent(profile) 114 + 115 + // ── kind-based boosting ── 116 + if (profile.personQuery && !profile.eventQuery) { 117 + if (hit.kind === "people") score -= 2 118 + if (hit.kind === "core") score -= 0.5 119 + if (hit.kind === "journal") score += 1.5 120 + } else if (profile.eventQuery && !profile.personQuery) { 121 + if (hit.kind === "journal") score -= 1.5 122 + if (hit.kind === "people") score += 0.75 123 + if (hit.kind === "core") score += 0.25 124 + } else { 125 + if (hit.kind === "people") score -= 0.5 126 + if (hit.kind === "core") score -= 0.25 127 + } 128 + 129 + // ── body token title overlap ── 130 + const titleLower = hitTitlePath(hit) 131 + if (profile.bodyTokens.some((token) => titleLower.includes(token))) score -= 0.35 132 + 133 + // ── person handle matching ── 134 + const handles = allPersonHandles(profile) 135 + if (handles.length > 0) { 136 + const fullHaystack = hitHaystack(hit) 137 + const pathHaystack = hit.path.toLowerCase() 138 + 139 + // Strong bonus: handle in filename/title/path 140 + if (handles.some((h) => titleLower.includes(h) || pathHaystack.includes(`/${h}.md`))) { 141 + score -= 3 142 + } else if (handles.some((h) => fullHaystack.includes(h))) { 143 + // Medium bonus: handle mentioned anywhere in text 144 + score -= 1 145 + } 146 + } 147 + 148 + // ── target person focus ── 149 + if (profile.bodyPeople.length > 0) { 150 + const targets = targetPersonHandles(profile) 151 + if (targets.some((handle) => handleInTitlePath(hit, handle))) { 152 + score -= 4 153 + } else if (hit.kind === "people") { 154 + score += 3 155 + } else if (hit.kind === "core" && !coreIntent) { 156 + score += 3.5 157 + } 158 + } 159 + 160 + // ── semantic similarity ── 161 + if (semanticSimilarity !== undefined) { 162 + score -= semanticSimilarity * 2.5 163 + if (hit.kind === "core" && !coreIntent && semanticSimilarity < MEMORY_SEMANTIC_STRONG_SIMILARITY) { 164 + score += 1.25 165 + } 166 + } else { 167 + score += 0.75 168 + if (hit.kind === "core" && !coreIntent) score += 1.25 169 + } 170 + 171 + // ── core penalty when no core intent ── 172 + if (hit.kind === "core" && !coreIntent) score += 0.75 173 + 174 + return score 175 + } 176 + 177 + // ── relevance gating ─────────────────────────────────────────────────── 178 + 179 + /** 180 + * Signal summarizing how the top hit overlaps with the query. 181 + */ 182 + type RelevanceSignal = { 183 + overlap: number 184 + strongOverlap: boolean 185 + bodyOverlap: number 186 + senderMatch: boolean 187 + } 188 + 189 + function computeRelevance(hit: MemoryHit, profile: MemorySearchProfile): RelevanceSignal { 190 + const haystack = hitHaystack(hit) 191 + let overlap = 0 192 + let strongOverlap = false 193 + let bodyOverlap = 0 194 + 195 + for (const token of profile.tokens) { 196 + if (!haystack.includes(token)) continue 197 + overlap += 1 198 + if (token.length >= 5 || /[0-9]/.test(token)) strongOverlap = true 199 + } 200 + for (const token of profile.bodyTokens) { 201 + if (haystack.includes(token)) bodyOverlap += 1 202 + } 203 + 204 + const handles = allPersonHandles(profile) 205 + const titleLower = hitTitlePath(hit) 206 + const pathHaystack = hit.path.toLowerCase() 207 + const senderMatch = 208 + handles.length > 0 && 209 + handles.some((h) => titleLower.includes(h) || pathHaystack.includes(`/${h}.md`) || haystack.includes(h)) 210 + 211 + return { overlap, strongOverlap, bodyOverlap, senderMatch } 212 + } 213 + 214 + /** 215 + * Decides whether the search results are relevant enough to inject into the conversation. 216 + * 217 + * @param hits - Scored hits (first element is the best match). 218 + * @param profile - Search profile. 219 + * @returns `true` when the hits should be injected. 220 + */ 221 + function isRelevant(hits: MemoryHit[], profile: MemorySearchProfile): boolean { 222 + if (hits.length === 0) return false 223 + 224 + const signal = computeRelevance(hits[0]!, profile) 225 + 226 + // Person-oriented queries require informative body + some match 227 + if (profile.sender || profile.bodyPeople.length > 0) { 228 + if (!profile.bodyInformative) return false 229 + if (signal.senderMatch) return true 230 + if (signal.bodyOverlap >= 2) return true 231 + if (signal.bodyOverlap >= 1 && signal.strongOverlap) return true 232 + return false 233 + } 234 + 235 + // Event queries require at least one journal match 236 + if (profile.eventQuery && !profile.personQuery) { 237 + return signal.overlap >= 1 && hits.some((hit) => hit.kind === "journal") 238 + } 239 + 240 + // Person queries require at least one people/core match 241 + if (profile.personQuery && !profile.eventQuery) { 242 + return signal.overlap >= 1 && hits.some((hit) => hit.kind === "people" || hit.kind === "core") 243 + } 244 + 245 + // General queries need decent overlap 246 + if (signal.bodyOverlap >= 2) return true 247 + if (signal.bodyOverlap >= 1 && signal.strongOverlap) return true 248 + if (signal.overlap >= 2 && signal.strongOverlap) return true 249 + return false 250 + } 251 + 252 + // ── search profile ───────────────────────────────────────────────────── 253 + 254 + function bestBodyBm25(bodyTokens: string[]): number | null { 255 + if (bodyTokens.length === 0) return null 256 + const query = bodyTokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" OR ") 257 + try { 258 + const row = getDb() 259 + .prepare( 260 + "select bm25(memory_chunks_fts, 5.0, 2.0, 1.0, 0.5) as r from memory_chunks_fts where memory_chunks_fts match ? order by r limit 1", 261 + ) 262 + .get(query) as { r: number } | undefined 263 + return row?.r ?? null 264 + } catch { 265 + return null 266 + } 267 + } 268 + 269 + function computeBodyInformativeness(bodyTokens: string[], bodyPeople: string[]): boolean { 270 + if (bodyPeople.length > 0) return true 271 + if (bodyTokens.length === 0) return false 272 + const top = bestBodyBm25(bodyTokens) 273 + if (top === null) return false 274 + return top <= BODY_INFORMATIVE_BM25_THRESHOLD 275 + } 276 + 277 + async function knownPeopleHandles(aliasMap: Record<string, string[]>): Promise<Set<string>> { 278 + const handles = new Set<string>() 279 + if (await import("fs/promises").then((fs) => fs.access(PEOPLE_DIR).then(() => true, () => false))) { 280 + const entries = await fs.readdir(PEOPLE_DIR, { withFileTypes: true }) 281 + for (const entry of entries) { 282 + if (!entry.isFile() || !entry.name.endsWith(".md")) continue 283 + const base = basenameWithoutExt(entry.name).toLowerCase() 284 + if (base) handles.add(base) 285 + } 286 + } 287 + for (const [key, values] of Object.entries(aliasMap)) { 288 + handles.add(key) 289 + for (const value of values) handles.add(value) 290 + } 291 + return handles 292 + } 293 + 294 + /** 295 + * Builds a rich search profile from query parts for use in scoring and gating. 296 + * 297 + * @param parts - Structured query parts (sender, source, body). 298 + * @returns Search profile with resolved aliases, tokens, and intent classification. 299 + */ 300 + export async function buildSearchProfile(parts: { 301 + sender: string | null 302 + source: string | null 303 + body: string 304 + }): Promise<MemorySearchProfile> { 305 + const aliasMap = await loadAliasMap() 306 + const sender = parts.sender ? normalizeHandle(parts.sender) : null 307 + const senderAliases = resolveAliases(sender, aliasMap) 308 + const bodyTokens = parts.body ? tokensFromText(parts.body) : [] 309 + 310 + const known = await knownPeopleHandles(aliasMap) 311 + const inlineMentions = parts.body 312 + ? Array.from(parts.body.matchAll(/@([a-z0-9_.-]+)/gi)).map((match) => normalizeHandle(match[1]!)) 313 + : [] 314 + const bodyPeopleSet = new Set<string>() 315 + const senderSet = new Set<string>([sender ?? "", ...senderAliases].filter(Boolean)) 316 + for (const token of [...bodyTokens, ...inlineMentions]) { 317 + if (!token || senderSet.has(token)) continue 318 + if (known.has(token)) bodyPeopleSet.add(token) 319 + } 320 + const bodyPeople = Array.from(bodyPeopleSet) 321 + for (const person of [...bodyPeople]) { 322 + for (const alias of resolveAliases(person, aliasMap)) { 323 + if (!senderSet.has(alias)) bodyPeopleSet.add(alias) 324 + } 325 + } 326 + const bodyPeopleResolved = Array.from(bodyPeopleSet) 327 + 328 + const combined: string[] = [] 329 + const seen = new Set<string>() 330 + const push = (value: string | null | undefined) => { 331 + if (!value) return 332 + const lower = value.toLowerCase() 333 + if (seen.has(lower)) return 334 + seen.add(lower) 335 + combined.push(lower) 336 + } 337 + push(sender) 338 + for (const alias of senderAliases) push(alias) 339 + for (const person of bodyPeopleResolved) push(person) 340 + for (const token of bodyTokens) push(token) 341 + 342 + const normalized = [sender ? `@${sender}` : "", parts.source ?? "", parts.body ?? ""] 343 + .filter(Boolean) 344 + .join(" ") 345 + .toLowerCase() 346 + 347 + const bodyInformative = computeBodyInformativeness(bodyTokens, bodyPeopleResolved) 348 + 349 + return { 350 + normalized, 351 + sender, 352 + senderAliases, 353 + bodyTokens, 354 + bodyPeople: bodyPeopleResolved, 355 + tokens: combined.slice(0, MEMORY_QUERY_TOKEN_LIMIT), 356 + bodyInformative, 357 + personQuery: 358 + Boolean(sender) || 359 + bodyPeopleResolved.length > 0 || 360 + /\b(who is|who's|tell me about|about)\b/.test(normalized) || 361 + /\bname\b/.test(normalized) || 362 + /\bfriend\b/.test(normalized), 363 + eventQuery: 364 + /\b(what happened|when|yesterday|today|tonight|earlier|before|after|session|wake)\b/.test( 365 + normalized, 366 + ) || 367 + /\b\d{4}-\d{2}-\d{2}\b/.test(normalized) || 368 + /\b\d{1,2}\/\d{1,2}(?:\/\d{2,4})?\b/.test(normalized) || 369 + /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/.test( 370 + normalized, 371 + ), 372 + } 373 + } 374 + 375 + // ── semantic queries ─────────────────────────────────────────────────── 376 + 377 + /** 378 + * Computes a semantic query signal using prototype embeddings for chatter/recall classification. 379 + * 380 + * @param memoryQuery - The search query string. 381 + * @returns Semantic signal with chatter and recall-intent similarity, or `null`. 382 + */ 383 + export async function semanticQuerySignal(memoryQuery: string): Promise<SemanticQuerySignal | null> { 384 + if (!isVecAvailable() || !embeddingsConfigured() || EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) return null 385 + const [vector] = await embedTexts([memoryQuery]) 386 + if (!vector || vector.length !== MEMORY_EMBEDDING_DIMENSIONS) return null 387 + 388 + const rows = getDb() 389 + .prepare(` 390 + select p.category as category, v.distance as distance 391 + from memory_prototype_vec v 392 + join memory_embedding_prototypes p on p.id = v.rowid 393 + where v.embedding match ? 394 + and k = ? 395 + order by v.distance 396 + `) 397 + .all(new Float32Array(vector), 8) as Array<{ category: string; distance: number }> 398 + 399 + let chatterSimilarity: number | null = null 400 + let recallIntentSimilarity: number | null = null 401 + for (const row of rows) { 402 + const similarity = 1 - row.distance 403 + if (row.category === "chatter") chatterSimilarity = Math.max(chatterSimilarity ?? -Infinity, similarity) 404 + if (row.category === "recall_intent") { 405 + recallIntentSimilarity = Math.max(recallIntentSimilarity ?? -Infinity, similarity) 406 + } 407 + } 408 + 409 + return { vector, chatterSimilarity, recallIntentSimilarity } 410 + } 411 + 412 + /** 413 + * Determines whether a query should be skipped because it's chatter (e.g. "i love you") 414 + * rather than genuine recall intent. 415 + * 416 + * @param profile - Search profile. 417 + * @param signal - Semantic query signal. 418 + * @returns `true` when the query looks like chatter and should be skipped. 419 + */ 420 + export function shouldSkipForSemanticChatter(profile: MemorySearchProfile, signal: SemanticQuerySignal | null): boolean { 421 + if (!signal) return false 422 + if (profileHasExplicitRecallIntent(profile)) return false 423 + if (profile.bodyTokens.length > 5) return false 424 + const chatter = signal.chatterSimilarity ?? 0 425 + const recallIntent = signal.recallIntentSimilarity ?? 0 426 + return chatter >= MEMORY_CHATTER_SIMILARITY_THRESHOLD && recallIntent < MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD 427 + } 428 + 429 + // ── FTS query building ───────────────────────────────────────────────── 430 + 431 + function buildSearchQuery(profile: MemorySearchProfile): string | null { 432 + if (profile.tokens.length === 0) return null 433 + return profile.tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" OR ") 434 + } 435 + 436 + function semanticDistancesForQuery(vector: number[], limit: number): Map<number, number> { 437 + if (!isVecAvailable()) return new Map() 438 + const rows = getDb() 439 + .prepare(` 440 + select rowid as chunkId, distance 441 + from memory_chunk_vec 442 + where embedding match ? 443 + and k = ? 444 + order by distance 445 + `) 446 + .all(new Float32Array(vector), limit) as Array<{ chunkId: number; distance: number }> 447 + return new Map(rows.map((row) => [row.chunkId, row.distance])) 448 + } 449 + 450 + // ── exact person hits ────────────────────────────────────────────────── 451 + 452 + function exactPersonHits(profile: MemorySearchProfile): MemoryHit[] { 453 + if (profile.bodyPeople.length === 0) return [] 454 + const db = getDb() 455 + const hits: MemoryHit[] = [] 456 + const seen = new Set<number>() 457 + const stmt = db.prepare(` 458 + select 459 + c.id as chunkId, 460 + d.path as path, 461 + d.kind as kind, 462 + d.title as documentTitle, 463 + c.title as title, 464 + c.heading_path as headingPath, 465 + c.chunk_text as text, 466 + -10.0 as rank 467 + from memory_chunks c 468 + join memory_documents d on d.id = c.document_id 469 + where d.kind = 'people' 470 + and lower(d.path) like ? 471 + order by c.chunk_index 472 + `) 473 + 474 + for (const handle of profile.bodyPeople) { 475 + const rows = stmt.all(`%/people/${handle.toLowerCase()}.md`) as MemoryHit[] 476 + for (const row of rows) { 477 + if (seen.has(row.chunkId)) continue 478 + seen.add(row.chunkId) 479 + hits.push(row) 480 + } 481 + } 482 + return hits 483 + } 484 + 485 + // ── cooldown ─────────────────────────────────────────────────────────── 486 + 487 + function isCoolingDown(hit: MemoryHit, cooldowns: Record<number, number>, currentTurn: number): boolean { 488 + const lastTurn = cooldowns[hit.chunkId] 489 + if (typeof lastTurn !== "number") return false 490 + return currentTurn - lastTurn < MEMORY_RECALL_COOLDOWN_TURNS 491 + } 492 + 493 + // ── main search ──────────────────────────────────────────────────────── 494 + 495 + /** 496 + * Searches the memory index for hits matching a search profile. 497 + * 498 + * @param profile - Search profile with query signals. 499 + * @param cooldowns - Chunk id → last-recalled turn mapping. 500 + * @param currentTurn - Current conversation turn number. 501 + * @param limit - Maximum number of hits to return. 502 + * @param semanticSignal - Optional semantic signal for similarity scoring. 503 + * @returns Deduplicated, scored, and filtered memory hits. 504 + */ 505 + export async function searchMemory( 506 + profile: MemorySearchProfile, 507 + cooldowns: Record<number, number>, 508 + currentTurn: number, 509 + limit: number, 510 + semanticSignal: SemanticQuerySignal | null = null, 511 + ): Promise<MemoryHit[]> { 512 + const db = getDb() 513 + const query = buildSearchQuery(profile) 514 + if (!query) return [] 515 + const rows = db 516 + .prepare(` 517 + select 518 + c.id as chunkId, 519 + d.path as path, 520 + d.kind as kind, 521 + d.title as documentTitle, 522 + c.title as title, 523 + c.heading_path as headingPath, 524 + c.chunk_text as text, 525 + bm25(memory_chunks_fts, 5.0, 2.0, 1.0, 0.5) as rank 526 + from memory_chunks_fts 527 + join memory_chunks c on c.id = memory_chunks_fts.rowid 528 + join memory_documents d on d.id = c.document_id 529 + where memory_chunks_fts match ? 530 + order by rank 531 + limit ? 532 + `) 533 + .all(query, Math.max(limit * 8, limit)) as MemoryHit[] 534 + 535 + // Inject exact person hits 536 + for (const hit of exactPersonHits(profile)) { 537 + if (rows.some((row) => row.chunkId === hit.chunkId)) continue 538 + rows.push(hit) 539 + } 540 + 541 + // Attach semantic distances 542 + if (semanticSignal) { 543 + const distances = semanticDistancesForQuery(semanticSignal.vector, Math.max(limit * 16, 64)) 544 + for (const row of rows) { 545 + const distance = distances.get(row.chunkId) 546 + if (distance === undefined) continue 547 + row.semanticDistance = distance 548 + row.semanticSimilarity = 1 - distance 549 + } 550 + } 551 + 552 + // Score all hits 553 + const scored = rows.map((hit) => ({ 554 + hit, 555 + score: scoreHit(hit, profile, hit.semanticSimilarity), 556 + })) 557 + 558 + // Filter by kind preference 559 + const kindFiltered = 560 + profile.personQuery && !profile.eventQuery 561 + ? (() => { 562 + const structured = scored.filter((s) => s.hit.kind === "people" || s.hit.kind === "core") 563 + return structured.length > 0 ? structured : scored 564 + })() 565 + : profile.eventQuery && !profile.personQuery 566 + ? (() => { 567 + const journal = scored.filter((s) => s.hit.kind === "journal") 568 + return journal.length > 0 ? journal : scored 569 + })() 570 + : scored 571 + 572 + // Sort by score (lower is better) 573 + kindFiltered.sort((a, b) => a.score - b.score) 574 + 575 + // Dedup with person-aware selection 576 + const deduped: MemoryHit[] = [] 577 + const seenChunkIds = new Set<number>() 578 + const seenPaths = new Set<string>() 579 + 580 + const handles = targetPersonHandles(profile) 581 + const exactTargetAvailable = 582 + profile.bodyPeople.length > 0 && kindFiltered.some((s) => handles.some((handle) => handleInTitlePath(s.hit, handle))) 583 + 584 + // First pass: ensure each target handle gets a representative hit 585 + if (handles.length >= 2) { 586 + for (const handle of handles) { 587 + if (deduped.length >= limit) break 588 + const candidate = kindFiltered.find( 589 + (s) => 590 + !seenChunkIds.has(s.hit.chunkId) && 591 + !seenPaths.has(s.hit.path) && 592 + !isCoolingDown(s.hit, cooldowns, currentTurn) && 593 + handleInTitlePath(s.hit, handle), 594 + ) 595 + if (!candidate) continue 596 + seenChunkIds.add(candidate.hit.chunkId) 597 + seenPaths.add(candidate.hit.path) 598 + deduped.push(candidate.hit) 599 + } 600 + } 601 + 602 + // Second pass: fill remaining slots 603 + for (const { hit } of kindFiltered) { 604 + if (deduped.length >= limit) break 605 + if (seenChunkIds.has(hit.chunkId)) continue 606 + if (isCoolingDown(hit, cooldowns, currentTurn)) continue 607 + 608 + const exactTargetMatch = profile.bodyPeople.length > 0 && handles.some((handle) => handleInTitlePath(hit, handle)) 609 + if (exactTargetAvailable && !exactTargetMatch && hit.kind !== "journal") continue 610 + 611 + // Require at least one person mention for person-oriented queries 612 + if (profile.bodyPeople.length > 0 && !profile.bodyPeople.some((handle) => hitHaystack(hit).includes(handle))) continue 613 + 614 + // Skip weak core hits without core intent 615 + if ( 616 + semanticSignal && 617 + hit.kind === "core" && 618 + !profileHasCoreIntent(profile) && 619 + (hit.semanticSimilarity ?? 0) < MEMORY_SEMANTIC_MIN_SIMILARITY 620 + ) continue 621 + 622 + seenChunkIds.add(hit.chunkId) 623 + deduped.push(hit) 624 + } 625 + 626 + return deduped 627 + } 628 + 629 + // ── formatting ───────────────────────────────────────────────────────── 630 + 631 + /** 632 + * Formats a memory hit into a human-readable source label. 633 + * 634 + * @param hit - Memory hit. 635 + * @returns Formatted source string. 636 + */ 637 + export function formatMemorySource(hit: MemoryHit): string { 638 + const relativePath = path.relative(HOME_DIR, hit.path) 639 + const pieces = [`[${hit.kind}]`, hit.documentTitle] 640 + if (hit.headingPath && hit.headingPath !== hit.documentTitle) pieces.push(hit.headingPath) 641 + pieces.push(relativePath) 642 + return pieces.join(" / ") 643 + } 644 + 645 + /** 646 + * Converts a raw hit into a public search result. 647 + * 648 + * @param hit - Memory hit. 649 + * @returns Search result with preview text. 650 + */ 651 + export function toMemorySearchResult(hit: MemoryHit): import("./shared").MemorySearchResult { 652 + return { 653 + chunkId: hit.chunkId, 654 + kind: hit.kind, 655 + path: hit.path, 656 + source: formatMemorySource(hit), 657 + title: hit.title, 658 + headingPath: hit.headingPath, 659 + preview: trimForPrompt(normalizeText(hit.text), 280), 660 + } 661 + } 662 + 663 + /** 664 + * Builds a formatted memory recall message from scored hits. 665 + * 666 + * @param hits - Hits to include. 667 + * @param maxChars - Maximum character budget. 668 + * @returns Formatted recall message. 669 + */ 670 + export function buildMemoryRecallMessage(hits: MemoryHit[], maxChars: number): string { 671 + const MEMORY_RECALL_HEADER = "[memory recall v1]" 672 + const MEMORY_RECALL_NOTE = 673 + "Potentially relevant long-term notes. Use only if helpful; trust newer conversation details if anything conflicts." 674 + 675 + const lines = [MEMORY_RECALL_HEADER, MEMORY_RECALL_NOTE, ""] 676 + let usedChars = lines.join("\n").length 677 + 678 + for (const hit of hits) { 679 + const source = formatMemorySource(hit) 680 + const remaining = Math.max(120, maxChars - usedChars - source.length - 10) 681 + const body = trimForPrompt(normalizeText(hit.text), Math.min(280, remaining)) 682 + const block = `- ${source}\n ${body}` 683 + 684 + if (usedChars + block.length > maxChars && lines.length > 3) break 685 + lines.push(block) 686 + usedChars += block.length + 1 687 + } 688 + 689 + return lines.join("\n").trim() 690 + } 691 + 692 + // Re-export `isRelevant` for the pipeline. 693 + export { isRelevant }
+276
src/memory/shared.ts
··· 1 + /** 2 + * Shared constants, types, and pure utility functions used across memory modules. 3 + * 4 + * @module memory/shared 5 + */ 6 + 7 + import fs from "fs/promises" 8 + import path from "path" 9 + import { HOME_DIR } from "../container/config" 10 + 11 + // ── directory layout ─────────────────────────────────────────────────── 12 + 13 + export const MEMORIES_DIR = path.join(HOME_DIR, "memories") 14 + export const JOURNAL_DIR = path.join(MEMORIES_DIR, "journal") 15 + export const PEOPLE_DIR = path.join(MEMORIES_DIR, "people") 16 + export const CORE_FILE = path.join(MEMORIES_DIR, "core.md") 17 + export const ALIASES_FILE = path.join(MEMORIES_DIR, "aliases.json") 18 + 19 + // ── tuning constants ─────────────────────────────────────────────────── 20 + 21 + export const MEMORY_RECALL_HEADER = "[memory recall v1]" 22 + export const MEMORY_RECALL_NOTE = 23 + "Potentially relevant long-term notes. Use only if helpful; trust newer conversation details if anything conflicts." 24 + export const MEMORY_RECALL_MAX_CHUNKS = 4 25 + export const MEMORY_RECALL_MAX_CHUNKS_HARD_CAP = 8 26 + export const MEMORY_RECALL_MAX_CHARS = 1_500 27 + export const MEMORY_RECALL_PER_EXTRA_PERSON_CHARS = 400 28 + export const MEMORY_QUERY_TOKEN_LIMIT = 12 29 + export const MEMORY_RECALL_COOLDOWN_TURNS = 7 30 + export const MEMORY_EMBEDDING_BATCH_SIZE = 24 31 + export const MEMORY_SEMANTIC_MIN_SIMILARITY = 0.18 32 + export const MEMORY_SEMANTIC_STRONG_SIMILARITY = 0.32 33 + export const MEMORY_CHATTER_SIMILARITY_THRESHOLD = 0.74 34 + export const MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD = 0.55 35 + export const SCHEDULED_HEARTBEAT_CONTENT = "Scheduled heartbeat." 36 + export const BODY_INFORMATIVE_BM25_THRESHOLD = -5 37 + 38 + export const MEMORY_EMBEDDING_PROTOTYPES = [ 39 + { id: 1, name: "affection-love", category: "chatter", text: "i love you so much sweetie <33" }, 40 + { id: 2, name: "cat-greeting", category: "chatter", text: "boop mraow meow hi sweetie" }, 41 + { id: 3, name: "celebration", category: "chatter", text: "yay yayy lets gooooo <33" }, 42 + { id: 4, name: "goodnight", category: "chatter", text: "goodnight sweet dreams rest well" }, 43 + { id: 101, name: "who-person", category: "recall_intent", text: "who is this person what do i know about them" }, 44 + { id: 102, name: "past-event", category: "recall_intent", text: "what happened before remember when that event happened" }, 45 + { id: 103, name: "task-context", category: "recall_intent", text: "what context do i need for this task or project" }, 46 + { id: 104, name: "system-lesson", category: "recall_intent", text: "what lesson or instruction should i remember here" }, 47 + ] as const 48 + 49 + export const MEMORY_STOP_WORDS = new Set([ 50 + "a", "an", "and", "are", "as", "at", "be", "been", "but", "by", 51 + "for", "from", "had", "has", "have", "he", "her", "hers", "him", 52 + "his", "i", "if", "in", "into", "is", "it", "its", "me", "my", 53 + "of", "on", "or", "our", "she", "that", "the", "their", "them", 54 + "there", "they", "this", "to", "up", "us", "was", "we", "were", 55 + "with", "you", "your", 56 + ]) 57 + 58 + // ── types ────────────────────────────────────────────────────────────── 59 + 60 + export type MemoryKind = "core" | "journal" | "people" 61 + 62 + export type MemoryDocumentRow = { 63 + id: number 64 + path: string 65 + content_hash: string 66 + mtime_ms: number 67 + kind?: string 68 + title?: string 69 + } 70 + 71 + export type MemoryChunkInput = { 72 + title: string 73 + headingPath: string | null 74 + text: string 75 + tags: string 76 + } 77 + 78 + export type MemoryHit = { 79 + chunkId: number 80 + path: string 81 + kind: MemoryKind 82 + documentTitle: string 83 + title: string 84 + headingPath: string | null 85 + text: string 86 + rank: number 87 + semanticDistance?: number 88 + semanticSimilarity?: number 89 + } 90 + 91 + export type MemorySearchResult = { 92 + chunkId: number 93 + kind: MemoryKind 94 + path: string 95 + source: string 96 + title: string 97 + headingPath: string | null 98 + preview: string 99 + } 100 + 101 + export type AliasMap = Record<string, string[]> 102 + 103 + export type MemorySearchProfile = { 104 + normalized: string 105 + sender: string | null 106 + senderAliases: string[] 107 + bodyTokens: string[] 108 + bodyPeople: string[] 109 + tokens: string[] 110 + personQuery: boolean 111 + eventQuery: boolean 112 + bodyInformative: boolean 113 + } 114 + 115 + export type SemanticQuerySignal = { 116 + vector: number[] 117 + chatterSimilarity: number | null 118 + recallIntentSimilarity: number | null 119 + } 120 + 121 + // ── pure utilities ───────────────────────────────────────────────────── 122 + 123 + /** 124 + * Normalizes line endings and collapses whitespace. 125 + * 126 + * @param value - Raw text. 127 + * @returns Cleaned text. 128 + */ 129 + export function normalizeText(value: string): string { 130 + return value.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim() 131 + } 132 + 133 + /** 134 + * Truncates text to a maximum character count, appending `...` when truncated. 135 + * 136 + * @param value - Text to trim. 137 + * @param maxChars - Maximum character count. 138 + * @returns Trimmed text. 139 + */ 140 + export function trimForPrompt(value: string, maxChars: number): string { 141 + if (value.length <= maxChars) return value 142 + if (maxChars <= 3) return ".".repeat(maxChars) 143 + return `${value.slice(0, maxChars - 3).trimEnd()}...` 144 + } 145 + 146 + /** 147 + * Returns the filename without extension. 148 + * 149 + * @param filePath - File path. 150 + * @returns Basename with extension removed. 151 + */ 152 + export function basenameWithoutExt(filePath: string): string { 153 + return path.basename(filePath, path.extname(filePath)) 154 + } 155 + 156 + /** 157 + * Splits text into paragraph-delimited chunks that fit within a character budget. 158 + * 159 + * @param text - Text to chunk. 160 + * @param maxChars - Maximum characters per chunk. 161 + * @returns Array of text chunks. 162 + */ 163 + export function chunkLargeSection(text: string, maxChars = 900): string[] { 164 + const paragraphs = text 165 + .split(/\n\s*\n/g) 166 + .map((part) => part.trim()) 167 + .filter(Boolean) 168 + 169 + if (paragraphs.length === 0) return [] 170 + 171 + const chunks: string[] = [] 172 + let current = "" 173 + 174 + for (const paragraph of paragraphs) { 175 + const next = current ? `${current}\n\n${paragraph}` : paragraph 176 + if (next.length <= maxChars || current.length === 0) { 177 + current = next 178 + continue 179 + } 180 + chunks.push(current) 181 + current = paragraph 182 + } 183 + 184 + if (current) chunks.push(current) 185 + return chunks 186 + } 187 + 188 + /** 189 + * Derives a human-readable title from a file path. 190 + * 191 + * @param filePath - File path. 192 + * @param fallback - Fallback title when the path yields nothing. 193 + * @returns Title string. 194 + */ 195 + export function titleFromPath(filePath: string, fallback: string): string { 196 + const base = basenameWithoutExt(filePath).replace(/[-_]+/g, " ").trim() 197 + return base ? base : fallback 198 + } 199 + 200 + /** 201 + * Classifies a memory file by its path. 202 + * 203 + * @param filePath - Absolute file path. 204 + * @returns Memory kind, or `null` when the path is outside known directories. 205 + */ 206 + export function detectMemoryKind(filePath: string): MemoryKind | null { 207 + if (filePath === CORE_FILE) return "core" 208 + if (filePath.startsWith(`${JOURNAL_DIR}${path.sep}`)) return "journal" 209 + if (filePath.startsWith(`${PEOPLE_DIR}${path.sep}`)) return "people" 210 + return null 211 + } 212 + 213 + /** 214 + * Checks whether a filesystem path exists. 215 + * 216 + * @param target - Path to check. 217 + * @returns `true` when accessible. 218 + */ 219 + export async function pathExists(target: string): Promise<boolean> { 220 + try { 221 + await fs.access(target) 222 + return true 223 + } catch { 224 + return false 225 + } 226 + } 227 + 228 + /** 229 + * Normalizes a user handle by trimming and removing leading `@` signs. 230 + * 231 + * @param handle - Raw handle string. 232 + * @returns Lowercased, cleaned handle. 233 + */ 234 + export function normalizeHandle(handle: string): string { 235 + return handle.trim().replace(/^@+/, "").toLowerCase() 236 + } 237 + 238 + /** 239 + * Normalizes body text for tokenization — strips mentions, ids, punctuation. 240 + * 241 + * @param raw - Raw text. 242 + * @returns Lowercased, cleaned text. 243 + */ 244 + export function normalizeBodyText(raw: string): string { 245 + return raw 246 + .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 247 + .replace(/\b\d{6,}\b/g, " ") 248 + .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 249 + .toLowerCase() 250 + .trim() 251 + } 252 + 253 + /** 254 + * Tokenizes text into deduplicated search terms, stopping at the token limit. 255 + * 256 + * @param raw - Raw text to tokenize. 257 + * @returns Array of unique meaningful tokens. 258 + */ 259 + export function tokensFromText(raw: string): string[] { 260 + const clean = normalizeBodyText(raw) 261 + const tokens = clean 262 + .split(/\s+/) 263 + .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 264 + .filter((token) => token.length >= 2 || /\d{2,}/.test(token)) 265 + .filter((token) => !MEMORY_STOP_WORDS.has(token)) 266 + 267 + const unique: string[] = [] 268 + const seen = new Set<string>() 269 + for (const token of tokens) { 270 + if (seen.has(token)) continue 271 + seen.add(token) 272 + unique.push(token) 273 + if (unique.length >= MEMORY_QUERY_TOKEN_LIMIT) break 274 + } 275 + return unique 276 + }
+489
src/memory/sync.ts
··· 1 + /** 2 + * Memory index synchronization — file walking, chunking, embedding sync. 3 + * 4 + * @module memory/sync 5 + */ 6 + 7 + import fs from "fs/promises" 8 + import path from "path" 9 + import { createHash } from "crypto" 10 + import { HOME_DIR } from "../container/config" 11 + import { getDb, isVecAvailable, MEMORY_EMBEDDING_DIMENSIONS } from "../db" 12 + import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL, embeddingsConfigured, embedTexts } from "../embeddings" 13 + import { 14 + basenameWithoutExt, 15 + chunkLargeSection, 16 + CORE_FILE, 17 + detectMemoryKind, 18 + JOURNAL_DIR, 19 + MEMORIES_DIR, 20 + MEMORY_EMBEDDING_BATCH_SIZE, 21 + MEMORY_EMBEDDING_PROTOTYPES, 22 + normalizeHandle, 23 + pathExists, 24 + PEOPLE_DIR, 25 + titleFromPath, 26 + type MemoryChunkInput, 27 + type MemoryDocumentRow, 28 + type MemoryKind, 29 + } from "./shared" 30 + 31 + // ── hashing ──────────────────────────────────────────────────────────── 32 + 33 + /** 34 + * Computes a SHA-1 content hash for deduplication. 35 + * 36 + * @param content - File content. 37 + * @returns Hex-encoded hash. 38 + */ 39 + export function contentHash(content: string): string { 40 + return createHash("sha1").update(content).digest("hex") 41 + } 42 + 43 + /** 44 + * Computes a SHA-256 hash for embedding input deduplication. 45 + * 46 + * @param content - Embedding input text. 47 + * @returns Hex-encoded hash. 48 + */ 49 + export function embeddingInputHash(content: string): string { 50 + return createHash("sha256").update(content).digest("hex") 51 + } 52 + 53 + /** 54 + * Wraps a number vector as a Float32Array for sqlite-vec. 55 + * 56 + * @param vector - Embedding vector. 57 + * @returns Float32Array suitable for sqlite-vec storage. 58 + */ 59 + export function vectorParam(vector: number[]): Float32Array { 60 + return new Float32Array(vector) 61 + } 62 + 63 + // ── file walking ─────────────────────────────────────────────────────── 64 + 65 + async function walkMarkdownFiles(root: string): Promise<string[]> { 66 + if (!(await pathExists(root))) return [] 67 + 68 + const found: string[] = [] 69 + const entries = await fs.readdir(root, { withFileTypes: true }) 70 + for (const entry of entries) { 71 + const fullPath = path.join(root, entry.name) 72 + if (entry.isDirectory()) { 73 + found.push(...(await walkMarkdownFiles(fullPath))) 74 + continue 75 + } 76 + if (entry.isFile() && entry.name.endsWith(".md")) found.push(fullPath) 77 + } 78 + 79 + return found.sort() 80 + } 81 + 82 + async function listMemoryFiles(): Promise<string[]> { 83 + const files: string[] = [] 84 + if (await pathExists(CORE_FILE)) files.push(CORE_FILE) 85 + files.push(...(await walkMarkdownFiles(JOURNAL_DIR))) 86 + files.push(...(await walkMarkdownFiles(PEOPLE_DIR))) 87 + return files 88 + } 89 + 90 + // ── markdown parsing / chunking ──────────────────────────────────────── 91 + 92 + /** 93 + * Splits a markdown document into titled sections and chunks large paragraphs. 94 + * 95 + * @param filePath - Absolute file path (used for title extraction). 96 + * @param content - Raw markdown content. 97 + * @returns Document title and array of chunk inputs. 98 + */ 99 + export function parseMarkdownDocument(filePath: string, content: string): { title: string; chunks: MemoryChunkInput[] } { 100 + const lines = content.replace(/\r\n/g, "\n").split("\n") 101 + const h1 = lines.find((line) => /^#\s+/.test(line)) 102 + const title = h1 ? h1.replace(/^#\s+/, "").trim() : titleFromPath(filePath, "Memory") 103 + const headingStack: string[] = [] 104 + let sectionLines: string[] = [] 105 + let sectionTitle = title 106 + const chunks: MemoryChunkInput[] = [] 107 + 108 + const flushSection = () => { 109 + const body = sectionLines.join("\n").trim() 110 + if (!body) { 111 + sectionLines = [] 112 + return 113 + } 114 + 115 + const headingPath = headingStack.length > 0 ? headingStack.join(" > ") : null 116 + const tags = [basenameWithoutExt(filePath), ...headingStack].join(" ").trim() 117 + for (const part of chunkLargeSection(body)) { 118 + chunks.push({ 119 + title: sectionTitle || title, 120 + headingPath, 121 + text: part, 122 + tags, 123 + }) 124 + } 125 + sectionLines = [] 126 + } 127 + 128 + for (const line of lines) { 129 + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/) 130 + if (!headingMatch) { 131 + sectionLines.push(line) 132 + continue 133 + } 134 + 135 + flushSection() 136 + 137 + const level = headingMatch[1]!.length 138 + const heading = headingMatch[2]!.trim() 139 + if (level === 1) { 140 + sectionTitle = heading || title 141 + headingStack.length = 0 142 + continue 143 + } 144 + 145 + while (headingStack.length >= level - 1) headingStack.pop() 146 + headingStack.push(heading) 147 + sectionTitle = heading || title 148 + } 149 + 150 + flushSection() 151 + 152 + if (chunks.length === 0) { 153 + const body = content.trim() 154 + if (body) { 155 + chunks.push({ 156 + title, 157 + headingPath: null, 158 + text: body, 159 + tags: basenameWithoutExt(filePath), 160 + }) 161 + } 162 + } 163 + 164 + return { title, chunks } 165 + } 166 + 167 + /** 168 + * Builds the embedding input text for a memory chunk. 169 + * 170 + * @param row - Chunk metadata. 171 + * @returns Multi-line embedding input. 172 + */ 173 + export function embeddingTextForChunk(row: { 174 + path: string 175 + kind: MemoryKind 176 + documentTitle: string 177 + title: string 178 + headingPath: string | null 179 + text: string 180 + tags?: string | null 181 + }): string { 182 + const relativePath = path.relative(HOME_DIR, row.path) 183 + return [ 184 + `kind: ${row.kind}`, 185 + `file: ${relativePath}`, 186 + `document: ${row.documentTitle}`, 187 + `title: ${row.title}`, 188 + row.headingPath ? `section: ${row.headingPath}` : null, 189 + row.tags ? `tags: ${row.tags}` : null, 190 + "", 191 + row.text, 192 + ] 193 + .filter((part): part is string => part !== null) 194 + .join("\n") 195 + } 196 + 197 + // ── index sync ───────────────────────────────────────────────────────── 198 + 199 + async function readMemoryDocumentRows(): Promise<Map<string, MemoryDocumentRow>> { 200 + const rows = getDb() 201 + .prepare("select id, path, kind, title, content_hash, mtime_ms from memory_documents") 202 + .all() as MemoryDocumentRow[] 203 + 204 + return new Map(rows.map((row) => [row.path, row])) 205 + } 206 + 207 + /** 208 + * Walks memory files, detects changes, and upserts chunks into the FTS index. 209 + * Triggers embedding sync afterward. 210 + */ 211 + export async function syncMemoryIndex(): Promise<void> { 212 + const db = getDb() 213 + const files = await listMemoryFiles() 214 + const known = await readMemoryDocumentRows() 215 + const present = new Set(files) 216 + 217 + const deleteChunksByDocument = db.prepare("delete from memory_chunks where document_id = ?") 218 + const insertDocument = db.prepare(` 219 + insert into memory_documents (path, kind, title, mtime_ms, content_hash, updated_at) 220 + values (@path, @kind, @title, @mtime_ms, @content_hash, datetime('now')) 221 + on conflict(path) do update set 222 + kind = excluded.kind, 223 + title = excluded.title, 224 + mtime_ms = excluded.mtime_ms, 225 + content_hash = excluded.content_hash, 226 + updated_at = datetime('now') 227 + `) 228 + const selectDocumentId = db.prepare("select id from memory_documents where path = ?") 229 + const insertChunk = db.prepare(` 230 + insert into memory_chunks (document_id, chunk_index, title, heading_path, chunk_text, tags) 231 + values (?, ?, ?, ?, ?, ?) 232 + `) 233 + const deleteDocumentByPath = db.prepare("delete from memory_documents where path = ?") 234 + 235 + const updates: Array<{ 236 + path: string 237 + kind: MemoryKind 238 + title: string 239 + mtimeMs: number 240 + hash: string 241 + chunks: MemoryChunkInput[] 242 + action: "inserted" | "updated" 243 + }> = [] 244 + 245 + for (const filePath of files) { 246 + const kind = detectMemoryKind(filePath) 247 + if (!kind) continue 248 + 249 + const [content, stat] = await Promise.all([fs.readFile(filePath, "utf-8"), fs.stat(filePath)]) 250 + const hash = contentHash(content) 251 + const previous = known.get(filePath) 252 + if (previous && previous.content_hash === hash && previous.mtime_ms === Math.floor(stat.mtimeMs)) continue 253 + 254 + const parsed = parseMarkdownDocument(filePath, content) 255 + updates.push({ 256 + path: filePath, 257 + kind, 258 + title: parsed.title, 259 + mtimeMs: Math.floor(stat.mtimeMs), 260 + hash, 261 + chunks: parsed.chunks, 262 + action: previous ? "updated" : "inserted", 263 + }) 264 + } 265 + 266 + const removedPaths: string[] = [] 267 + 268 + db.transaction(() => { 269 + for (const item of updates) { 270 + insertDocument.run({ 271 + path: item.path, 272 + kind: item.kind, 273 + title: item.title, 274 + mtime_ms: item.mtimeMs, 275 + content_hash: item.hash, 276 + }) 277 + const row = selectDocumentId.get(item.path) as { id: number } | undefined 278 + if (!row) continue 279 + 280 + deleteChunksByDocument.run(row.id) 281 + item.chunks.forEach((chunk, index) => { 282 + insertChunk.run(row.id, index, chunk.title, chunk.headingPath, chunk.text, chunk.tags) 283 + }) 284 + 285 + console.log( 286 + `[memory] ${item.action} kind=${item.kind} chunks=${item.chunks.length} path=${path.relative(HOME_DIR, item.path)}`, 287 + ) 288 + } 289 + 290 + for (const filePath of known.keys()) { 291 + if (present.has(filePath)) continue 292 + deleteDocumentByPath.run(filePath) 293 + removedPaths.push(filePath) 294 + console.log(`[memory] removed path=${path.relative(HOME_DIR, filePath)}`) 295 + } 296 + })() 297 + 298 + if (updates.length === 0 && removedPaths.length === 0) { 299 + await syncMemoryEmbeddings() 300 + return 301 + } 302 + 303 + await syncMemoryEmbeddings() 304 + } 305 + 306 + // ── embedding sync ───────────────────────────────────────────────────── 307 + 308 + let embeddingSkipWarned = false 309 + 310 + type MemoryEmbeddingRow = { 311 + chunkId: number 312 + path: string 313 + kind: MemoryKind 314 + documentTitle: string 315 + title: string 316 + headingPath: string | null 317 + text: string 318 + tags: string | null 319 + model: string | null 320 + dimensions: number | null 321 + contentHash: string | null 322 + } 323 + 324 + async function syncMemoryEmbeddings(): Promise<void> { 325 + if (!isVecAvailable()) return 326 + if (!embeddingsConfigured()) { 327 + if (!embeddingSkipWarned) { 328 + console.warn("[memory] embeddings disabled: set EMBEDDING_API_KEY") 329 + embeddingSkipWarned = true 330 + } 331 + return 332 + } 333 + if (EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) { 334 + if (!embeddingSkipWarned) { 335 + console.warn( 336 + `[memory] embeddings disabled: EMBEDDING_DIMENSIONS=${EMBEDDING_DIMENSIONS} but sqlite-vec table is ${MEMORY_EMBEDDING_DIMENSIONS}`, 337 + ) 338 + embeddingSkipWarned = true 339 + } 340 + return 341 + } 342 + 343 + const db = getDb() 344 + try { 345 + await syncMemoryEmbeddingPrototypes() 346 + } catch (err: any) { 347 + console.warn(`[memory] prototype embedding sync failed: ${err?.message ?? String(err)}`) 348 + return 349 + } 350 + db.prepare("delete from memory_embedding_meta where chunk_id not in (select id from memory_chunks)").run() 351 + db.prepare("delete from memory_chunk_vec where rowid not in (select id from memory_chunks)").run() 352 + 353 + const rows = db 354 + .prepare(` 355 + select 356 + c.id as chunkId, 357 + d.path as path, 358 + d.kind as kind, 359 + d.title as documentTitle, 360 + c.title as title, 361 + c.heading_path as headingPath, 362 + c.chunk_text as text, 363 + c.tags as tags, 364 + m.model as model, 365 + m.dimensions as dimensions, 366 + m.content_hash as contentHash 367 + from memory_chunks c 368 + join memory_documents d on d.id = c.document_id 369 + left join memory_embedding_meta m on m.chunk_id = c.id 370 + order by d.kind, d.path, c.chunk_index 371 + `) 372 + .all() as MemoryEmbeddingRow[] 373 + 374 + const pending = rows 375 + .map((row) => { 376 + const text = embeddingTextForChunk(row) 377 + return { ...row, embeddingText: text, embeddingHash: embeddingInputHash(text) } 378 + }) 379 + .filter( 380 + (row) => 381 + row.model !== EMBEDDING_MODEL || 382 + row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 383 + row.contentHash !== row.embeddingHash, 384 + ) 385 + 386 + if (pending.length === 0) return 387 + 388 + const upsertMeta = db.prepare(` 389 + insert into memory_embedding_meta (chunk_id, model, dimensions, content_hash, updated_at) 390 + values (?, ?, ?, ?, datetime('now')) 391 + on conflict(chunk_id) do update set 392 + model = excluded.model, 393 + dimensions = excluded.dimensions, 394 + content_hash = excluded.content_hash, 395 + updated_at = datetime('now') 396 + `) 397 + const upsertVector = db.prepare("insert or replace into memory_chunk_vec(rowid, embedding) values (?, ?)") 398 + 399 + let embedded = 0 400 + for (let i = 0; i < pending.length; i += MEMORY_EMBEDDING_BATCH_SIZE) { 401 + const batch = pending.slice(i, i + MEMORY_EMBEDDING_BATCH_SIZE) 402 + let vectors: number[][] 403 + try { 404 + vectors = await embedTexts(batch.map((row) => row.embeddingText)) 405 + } catch (err: any) { 406 + console.warn(`[memory] embedding batch failed: ${err?.message ?? String(err)}`) 407 + return 408 + } 409 + 410 + db.transaction(() => { 411 + batch.forEach((row, index) => { 412 + const vector = vectors[index] 413 + if (!vector) return 414 + if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 415 + throw new Error(`embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 416 + } 417 + upsertVector.run(BigInt(row.chunkId), vectorParam(vector)) 418 + upsertMeta.run(row.chunkId, EMBEDDING_MODEL, MEMORY_EMBEDDING_DIMENSIONS, row.embeddingHash) 419 + embedded += 1 420 + }) 421 + })() 422 + } 423 + 424 + console.log(`[memory] embedded chunks=${embedded} model=${EMBEDDING_MODEL} dimensions=${MEMORY_EMBEDDING_DIMENSIONS}`) 425 + } 426 + 427 + async function syncMemoryEmbeddingPrototypes(): Promise<void> { 428 + const db = getDb() 429 + const rows = db 430 + .prepare("select id, name, category, model, dimensions, content_hash as contentHash from memory_embedding_prototypes") 431 + .all() as Array<{ 432 + id: number 433 + name: string 434 + category: string 435 + model: string 436 + dimensions: number 437 + contentHash: string 438 + }> 439 + const known = new Map(rows.map((row) => [row.id, row])) 440 + const pending = MEMORY_EMBEDDING_PROTOTYPES.map((prototype) => ({ 441 + ...prototype, 442 + hash: embeddingInputHash(`${prototype.category}\n${prototype.name}\n${prototype.text}`), 443 + })).filter((prototype) => { 444 + const row = known.get(prototype.id) 445 + return ( 446 + !row || 447 + row.name !== prototype.name || 448 + row.category !== prototype.category || 449 + row.model !== EMBEDDING_MODEL || 450 + row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 451 + row.contentHash !== prototype.hash 452 + ) 453 + }) 454 + 455 + if (pending.length === 0) return 456 + 457 + const vectors = await embedTexts(pending.map((prototype) => prototype.text)) 458 + const upsertPrototype = db.prepare(` 459 + insert into memory_embedding_prototypes (id, name, category, model, dimensions, content_hash, updated_at) 460 + values (?, ?, ?, ?, ?, ?, datetime('now')) 461 + on conflict(id) do update set 462 + name = excluded.name, 463 + category = excluded.category, 464 + model = excluded.model, 465 + dimensions = excluded.dimensions, 466 + content_hash = excluded.content_hash, 467 + updated_at = datetime('now') 468 + `) 469 + const upsertVector = db.prepare("insert or replace into memory_prototype_vec(rowid, embedding) values (?, ?)") 470 + 471 + db.transaction(() => { 472 + pending.forEach((prototype, index) => { 473 + const vector = vectors[index] 474 + if (!vector) return 475 + if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 476 + throw new Error(`prototype embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 477 + } 478 + upsertVector.run(BigInt(prototype.id), vectorParam(vector)) 479 + upsertPrototype.run( 480 + prototype.id, 481 + prototype.name, 482 + prototype.category, 483 + EMBEDDING_MODEL, 484 + MEMORY_EMBEDDING_DIMENSIONS, 485 + prototype.hash, 486 + ) 487 + }) 488 + })() 489 + }
+5 -5
src/metrics.ts
··· 2 2 import fs from "fs" 3 3 import path from "path" 4 4 import type OpenAI from "openai" 5 - import { HOME_DIR } from "./container/config.js" 6 - import { getDb } from "./db.js" 7 - import type { Message } from "./types.js" 8 - import type { MemorySearchResult } from "./memory.js" 5 + import { HOME_DIR } from "./container/config" 6 + import { getDb } from "./db" 7 + import type { Message } from "./types" 8 + import type { MemorySearchResult } from "./memory/index" 9 9 10 10 export interface BaseMetricEvent { 11 11 timestamp: string ··· 172 172 return new Date(Date.now() - days * 24 * 60 * 60_000).toISOString() 173 173 } 174 174 175 - export function pruneOldMetrics(days = metricsRetentionDays()): number { 175 + function pruneOldMetrics(days = metricsRetentionDays()): number { 176 176 if (!db) return 0 177 177 178 178 const cutoff = metricsRetentionCutoff(days)
+8 -8
src/runner/index.ts
··· 1 - import { buildBootstrap } from "../bootstrap.js" 2 - import { endConversation, logMessage, startConversation } from "../db.js" 3 - import { emit } from "../stream.js" 4 - import { runLoop } from "./loop.js" 5 - import { setRunnerPresence } from "./presence.js" 6 - import type { RunnerStateInternal } from "./types.js" 7 - import { clearSession, loadSession, saveSession } from "./util.js" 8 - import type { UserMessage } from "../types.js" 1 + import { buildBootstrap } from "../bootstrap" 2 + import { endConversation, logMessage, startConversation } from "../db" 3 + import { emit } from "../stream" 4 + import { runLoop } from "./loop" 5 + import { setRunnerPresence } from "./presence" 6 + import type { RunnerStateInternal } from "./types" 7 + import { clearSession, loadSession, saveSession } from "./util" 8 + import type { UserMessage } from "../types" 9 9 10 10 let eventResolvers: Array<(event: UserMessage) => void> = [] 11 11 let shutdownResolve: (() => void) | null = null
+1 -1
src/runner/loop-completion.test.ts
··· 1 1 import assert from "node:assert/strict" 2 2 import test from "node:test" 3 - import { __completionTest } from "./loop-completion.js" 3 + import { __completionTest } from "./loop-completion" 4 4 5 5 test("consumeCompletionStream preserves reasoning_content on assistant messages", async () => { 6 6 async function* stream() {
+8 -8
src/runner/loop-completion.ts
··· 1 1 import OpenAI from "openai" 2 - import { logMessage } from "../db.js" 3 - import { buildCompletionMessages, rememberRecalledMemoryChunks } from "../memory.js" 4 - import { recordMetric } from "../metrics.js" 5 - import { emit } from "../stream.js" 6 - import type { LoopState } from "./types.js" 2 + import { logMessage } from "../db" 3 + import { buildCompletionMessages, rememberRecalledMemoryChunks } from "../memory" 4 + import { recordMetric } from "../metrics" 5 + import { emit } from "../stream" 6 + import type { LoopState } from "./types" 7 7 import { 8 8 API_BASE, 9 9 ENABLE_THINKING, ··· 28 28 shouldFallback, 29 29 summaryClient, 30 30 summarizeConversationViaLLM, 31 - } from "./util.js" 32 - import { assistantContentText } from "./loop-content.js" 33 - import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared.js" 31 + } from "./util" 32 + import { assistantContentText } from "./loop-content" 33 + import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared" 34 34 35 35 /** 36 36 * Resolves the configured summary client/model pair.
+2 -2
src/runner/loop-content.ts
··· 1 1 import OpenAI from "openai" 2 - import type { LoopState } from "./types.js" 3 - import type { FunctionToolCall } from "./loop-shared.js" 2 + import type { LoopState } from "./types" 3 + import type { FunctionToolCall } from "./loop-shared" 4 4 5 5 /** 6 6 * Normalizes assistant/tool message content into plain text.
+1 -1
src/runner/loop-shared.ts
··· 1 1 import OpenAI from "openai" 2 - import type { LoopHooks, LoopState } from "./types.js" 2 + import type { LoopHooks, LoopState } from "./types" 3 3 4 4 export type FunctionToolCall = OpenAI.Chat.ChatCompletionMessageToolCall & { type: "function" } 5 5
+3 -3
src/runner/loop-signatures.ts
··· 1 1 import OpenAI from "openai" 2 - import { assistantContentText } from "./loop-content.js" 3 - import { parseToolArguments } from "./util.js" 4 - import type { FunctionToolCall } from "./loop-shared.js" 2 + import { assistantContentText } from "./loop-content" 3 + import { parseToolArguments } from "./util" 4 + import type { FunctionToolCall } from "./loop-shared" 5 5 6 6 const MAX_TOOL_RESULT_SIGNATURE_CHARS = 600 7 7
+9 -9
src/runner/loop-tool-registry.ts
··· 1 1 import OpenAI from "openai" 2 - import { normalizeTimeoutMs } from "../container/config.js" 3 - import { editFile, readFile, readImageForModel, runCommand } from "../container/index.js" 4 - import { logMessage } from "../db.js" 2 + import { normalizeTimeoutMs } from "../container/config" 3 + import { editFile, readFile, readImageForModel, runCommand } from "../container/index" 4 + import { logMessage } from "../db" 5 5 import { 6 6 listDiscordBackread, 7 7 listDiscordChannels, ··· 10 10 scanDiscordChannels, 11 11 sendDiscordMessage, 12 12 setDiscordChannelNote, 13 - } from "../discord/state.js" 14 - import { listAliases, removeAlias, searchMemories, setAlias } from "../memory.js" 15 - import { emit } from "../stream.js" 16 - import type { ToolHandler } from "./loop-shared.js" 17 - import { pushToolMessage, recordToolResult, runStandardTool, toolError } from "./loop-tool-runtime.js" 18 - import { parseImageDetail } from "./util.js" 13 + } from "../discord/state" 14 + import { listAliases, removeAlias, searchMemories, setAlias } from "../memory" 15 + import { emit } from "../stream" 16 + import type { ToolHandler } from "./loop-shared" 17 + import { pushToolMessage, recordToolResult, runStandardTool, toolError } from "./loop-tool-runtime" 18 + import { parseImageDetail } from "./util" 19 19 20 20 const DEFAULT_WAIT_THEN_CONTINUE_MS = 10_000 21 21
+6 -6
src/runner/loop-tool-runtime.ts
··· 1 1 import OpenAI from "openai" 2 - import { logMessage } from "../db.js" 3 - import { emit } from "../stream.js" 4 - import { latestAssistantContent } from "./loop-content.js" 2 + import { logMessage } from "../db" 3 + import { emit } from "../stream" 4 + import { latestAssistantContent } from "./loop-content" 5 5 import type { 6 6 ArgTuple, 7 7 FunctionToolCall, ··· 10 10 ToolExecutionContext, 11 11 ToolExecutionOutcome, 12 12 ToolHandler, 13 - } from "./loop-shared.js" 14 - import type { LoopHooks, LoopState } from "./types.js" 15 - import { parseToolArguments } from "./util.js" 13 + } from "./loop-shared" 14 + import type { LoopHooks, LoopState } from "./types" 15 + import { parseToolArguments } from "./util" 16 16 17 17 type StandardToolSpec< 18 18 RunKeys extends readonly ToolArgKey[],
+5 -5
src/runner/loop-tools.ts
··· 1 - import { emit } from "../stream.js" 2 - import { buildToolHandlers } from "./loop-tool-registry.js" 3 - import { executeToolCall, pushToolMessage } from "./loop-tool-runtime.js" 4 - import type { FunctionToolCall } from "./loop-shared.js" 5 - import type { LoopHooks, LoopState } from "./types.js" 1 + import { emit } from "../stream" 2 + import { buildToolHandlers } from "./loop-tool-registry" 3 + import { executeToolCall, pushToolMessage } from "./loop-tool-runtime" 4 + import type { FunctionToolCall } from "./loop-shared" 5 + import type { LoopHooks, LoopState } from "./types" 6 6 7 7 function skipRemainingToolCalls( 8 8 convId: number,
+4 -4
src/runner/loop.test.ts
··· 1 1 import assert from "node:assert/strict" 2 2 import test from "node:test" 3 - import { __loopTest } from "./loop.js" 4 - import type { Message } from "../types.js" 5 - import type { LoopState } from "./types.js" 6 - import type { Message } from "../types.js" 3 + import { __loopTest } from "./loop" 4 + import type { Message } from "../types" 5 + import type { LoopState } from "./types" 6 + import type { Message } from "../types" 7 7 8 8 function makeState(): LoopState { 9 9 return {
+9 -9
src/runner/loop.ts
··· 1 - import { recordMetric } from "../metrics.js" 2 - import { emit } from "../stream.js" 3 - import type { Message } from "../types.js" 1 + import { recordMetric } from "../metrics" 2 + import { emit } from "../stream" 3 + import type { Message } from "../types" 4 4 import { 5 5 CONTEXT_COMPACT_TRIGGER_TOKENS, 6 6 ENABLE_THINKING, ··· 10 10 estimatePromptTokens, 11 11 findSummaryMessageIndex, 12 12 summarizeConversationViaLLM, 13 - } from "./util.js" 14 - import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion.js" 15 - import { assistantContentText, isFunctionToolCall } from "./loop-content.js" 16 - import { buildTurnSignature, hasIncomingUserMessage } from "./loop-signatures.js" 17 - import { processToolCalls } from "./loop-tools.js" 18 - import type { LoopHooks, LoopState } from "./types.js" 13 + } from "./util" 14 + import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion" 15 + import { assistantContentText, isFunctionToolCall } from "./loop-content" 16 + import { buildTurnSignature, hasIncomingUserMessage } from "./loop-signatures" 17 + import { processToolCalls } from "./loop-tools" 18 + import type { LoopHooks, LoopState } from "./types" 19 19 20 20 const LLM_POST_TURN_RECENT_MESSAGES = 40 21 21 const RUNNER_MAX_TURNS = parsePositiveIntEnv(process.env.RUNNER_MAX_TURNS, 120)
-4
src/runner/presence.ts
··· 5 5 const listeners = new Set<Listener>() 6 6 let currentPresence: RunnerPresence = "resting" 7 7 8 - export function getRunnerPresence(): RunnerPresence { 9 - return currentPresence 10 - } 11 - 12 8 export function setRunnerPresence(presence: RunnerPresence): void { 13 9 if (presence === currentPresence) return 14 10 currentPresence = presence
+1 -5
src/runner/types.ts
··· 1 - import type { Message, RunnerState, UserMessage } from "../types.js" 2 - 3 - /** Generic decoded tool arguments object. */ 4 - export type ToolArgs = Record<string, any> 1 + import type { Message, RunnerState, UserMessage } from "../types" 5 2 6 - /** Supported image detail levels for multimodal requests. */ 7 3 export type ImageDetail = "auto" | "low" | "high" 8 4 9 5 /** Mutable state consumed by the runner loop on each turn. */
+1 -1
src/runner/util.test.ts
··· 1 1 import assert from "node:assert/strict" 2 2 import test from "node:test" 3 3 import type OpenAI from "openai" 4 - import { isTransientTransportError, sanitizeMessages, shouldFallback } from "./util.js" 4 + import { isTransientTransportError, sanitizeMessages, shouldFallback } from "./util" 5 5 6 6 type AssistantMessageWithReasoning = OpenAI.Chat.ChatCompletionAssistantMessageParam & { 7 7 reasoning_content?: string
+4 -3
src/runner/util.ts
··· 2 2 import path from "path" 3 3 import { fileURLToPath } from "url" 4 4 import OpenAI from "openai" 5 - import { imageRootForModelInput } from "../container/index.js" 6 - import type { Message } from "../types.js" 7 - import type { ImageDetail, ToolArgs } from "./types.js" 5 + import { imageRootForModelInput } from "../container/index" 6 + import type { Message } from "../types" 7 + import type { ImageDetail } from "./types" 8 + import type { ToolArgs } from "./loop-shared" 8 9 9 10 const PROJECT_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../..") 10 11 const SESSION_FILE = path.join(PROJECT_ROOT, "session.json")
+11 -11
src/server.ts
··· 3 3 import { existsSync } from "node:fs" 4 4 import { dirname, join } from "node:path" 5 5 import { fileURLToPath } from "node:url" 6 - import { wake, isRunning, isWaitingForEvent, enqueueEvent } from "./runner/index.js" 7 - import { buildDiscordBatchDigest, scanDiscordChannels } from "./discord/state.js" 8 - import { handleDiscordIngress } from "./discord/pipeline.js" 9 - import { fromBsky } from "./triggers/bsky.js" 10 - import { fromWebhook } from "./triggers/webhook.js" 11 - import { fromCron } from "./triggers/cron.js" 12 - import { fromChat } from "./triggers/chat.js" 13 - import { subscribe } from "./stream.js" 14 - import { getMetrics, getMetricDetail, getDiscordMetricDetail } from "./metrics.js" 15 - import type { MetricListType } from "./metrics.js" 16 - import type { UserMessage } from "./types.js" 6 + import { wake, isRunning, isWaitingForEvent, enqueueEvent } from "./runner/index" 7 + import { buildDiscordBatchDigest, scanDiscordChannels } from "./discord/state" 8 + import { handleDiscordIngress } from "./discord/pipeline" 9 + import { fromBsky } from "./triggers/bsky" 10 + import { fromWebhook } from "./triggers/webhook" 11 + import { fromCron } from "./triggers/cron" 12 + import { fromChat } from "./triggers/chat" 13 + import { subscribe } from "./stream" 14 + import { getMetrics, getMetricDetail, getDiscordMetricDetail } from "./metrics" 15 + import type { MetricListType } from "./metrics" 16 + import type { UserMessage } from "./types" 17 17 18 18 const SRC_DIR = dirname(fileURLToPath(import.meta.url)) 19 19 const WEB_DIST_DIR = join(SRC_DIR, "..", "apps", "web", "dist")
+4 -6
src/stream.ts
··· 1 - export type StreamEvent = 2 - | { type: "text"; text: string } 3 - | { type: "user"; text: string; source: string; triggeredAt: string; clientId?: string } 4 - | { type: "thinking"; text: string } 5 - | { type: "tool"; name: string; args: Record<string, unknown>; result: string } 1 + import type { StreamEvent } from "@niri/chat-client" 2 + 3 + export type { StreamEvent } 6 4 7 - export type Listener = (event: StreamEvent) => void 5 + type Listener = (event: StreamEvent) => void 8 6 9 7 const listeners = new Set<Listener>() 10 8 const BUFFER_SIZE = 50
+1 -1
src/triggers/bsky.ts
··· 1 - import type { UserMessage } from "../types.js" 1 + import type { UserMessage } from "../types" 2 2 3 3 export function fromBsky(body: unknown): UserMessage { 4 4 const b = body as Record<string, unknown>
+1 -1
src/triggers/chat.ts
··· 1 - import type { UserMessage } from "../types.js" 1 + import type { UserMessage } from "../types" 2 2 3 3 export function fromChat(body: unknown): UserMessage { 4 4 const b = body as Record<string, unknown>
+1 -1
src/triggers/cron.ts
··· 1 - import type { UserMessage } from "../types.js" 1 + import type { UserMessage } from "../types" 2 2 3 3 export function fromCron(): UserMessage { 4 4 return {
+4 -14
src/triggers/discord.ts
··· 1 - import type { UserMessage } from "../types.js" 2 - import { getDb } from "../db.js" 1 + import type { UserMessage } from "../types" 2 + import { getDb } from "../db" 3 + import { asObject, asString } from "../discord/parse" 3 4 4 5 function asIsoTimestamp(value: unknown, fallback: string): string { 5 6 if (typeof value !== "string" && typeof value !== "number") return fallback ··· 7 8 return Number.isNaN(parsed.getTime()) ? fallback : parsed.toISOString() 8 9 } 9 10 10 - function asRecord(value: unknown): Record<string, unknown> | null { 11 - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null 12 - } 13 - 14 - function asString(value: unknown): string | null { 15 - if (typeof value === "string") { 16 - const trimmed = value.trim() 17 - return trimmed.length > 0 ? trimmed : null 18 - } 19 - if (typeof value === "number" && Number.isFinite(value)) return String(value) 20 - return null 21 - } 11 + const asRecord = asObject 22 12 23 13 function referencedMessageId(message: Record<string, unknown>, body: Record<string, unknown>): string | null { 24 14 const reference =
+1 -1
src/triggers/webhook.ts
··· 1 - import type { UserMessage } from "../types.js" 1 + import type { UserMessage } from "../types" 2 2 3 3 export function fromWebhook(body: unknown): UserMessage { 4 4 const b = body as Record<string, unknown>
+2 -2
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "target": "ES2022", 4 - "module": "NodeNext", 5 - "moduleResolution": "NodeNext", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 6 "strict": true, 7 7 "esModuleInterop": true, 8 8 "resolveJsonModule": true