import type { MemoryId } from '../../types/ids/index.js' import type { MemoryIndex, MemoryIndexEntry, MemorySearchParams, MemorySearchResult, } from '../../types/memory/index.js' function terms(text: string): Set { return new Set( text .normalize('NFKC') .toLowerCase() .match(/[\p{L}\p{N}]+/gu) ?? [], ) } /** Exact normalized word membership; no substring, stemming or alias inference. */ export function matchesMemoryIdentifier(text: string, identifiers: ReadonlySet): boolean { for (const match of text .normalize('NFKC') .toLowerCase() .matchAll(/[\p{L}\p{N}_]+/gu)) { if (identifiers.has(match[0])) return true } return false } /** Shared lexical ranking; a caller-owned index can omit body content. */ export function searchMemoryEntries( entries: readonly MemoryIndexEntry[], params: MemorySearchParams, contentOf: (id: MemoryId) => string = () => '', ): MemorySearchResult { const query = terms(params.query ?? '') const identifiers = new Set( params.requiredIdentifiers?.map((id) => id.normalize('NFKC').toLowerCase()), ) if (params.query?.trim() && query.size === 0) return { entries: [], totalCount: 0 } const ranked = entries .filter( (entry) => (!params.status || entry.status === params.status) && (!params.tags?.length || params.tags.every((tag) => entry.tags.includes(tag))), ) .filter( (entry) => !identifiers.size || [entry.id, entry.title, entry.summary, contentOf(entry.id)].some((text) => matchesMemoryIdentifier(text, identifiers), ), ) .map((entry) => { let coverage = 0 let score = 0 if (query.size > 0) { // A name is a title spelled as a slug and a description is a // summary kept to one line; each scores as the field it stands in for. const title = terms(`${entry.title} ${(entry.name ?? '').replace(/-/g, ' ')}`) const summary = terms(`${entry.summary} ${entry.description ?? ''}`) const body = terms(contentOf(entry.id)) for (const term of query) { const weight = (title.has(term) ? 8 : 0) + (summary.has(term) ? 4 : 0) + (body.has(term) ? 1 : 0) if (weight > 0) coverage++ score += weight } } return { entry, coverage, score } }) .filter(({ coverage }) => query.size === 0 || coverage > 0) .sort( (a, b) => b.coverage - a.coverage || b.score - a.score || b.entry.updatedAt - a.entry.updatedAt || (a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0), ) return { entries: ranked.slice(0, params.limit ?? ranked.length).map(({ entry }) => entry), totalCount: ranked.length, } } export class InMemoryMemoryIndex implements MemoryIndex { private entries = new Map() search(params: MemorySearchParams): MemorySearchResult { return searchMemoryEntries([...this.entries.values()], params) } getEntry(id: MemoryId): MemoryIndexEntry | undefined { return this.entries.get(id) } allEntries(): readonly MemoryIndexEntry[] { return Array.from(this.entries.values()) } count(): number { return this.entries.size } rebuild(entries: MemoryIndexEntry[]): void { this.entries.clear() for (const entry of entries) { this.entries.set(entry.id, entry) } } set(entry: MemoryIndexEntry): void { this.entries.set(entry.id, entry) } remove(id: MemoryId): boolean { return this.entries.delete(id) } }