UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

98 lines (91 loc) • 3.87 kB
import type { MyOMemoryConfig } from "../config.ts"; export interface KeywordHit { content: string; pattern: string; /** True when the hit came from a personal-scope pattern: force personal scope. */ personal: boolean; } /** * D67: capture-body length bounds. The lower bound rejects fragments — a * trigger that yields "这个" or "OK" is a match on the trigger, not a memory, * and a 2-char memory is noise in every future session's injection. The floor * stays deliberately above 1: D67 considered lowering it and decided against * it. What changed is that a rejection is no longer silent (see KeywordDrop). */ export const MIN_CAPTURE_CHARS = 3; export const MAX_CAPTURE_CHARS = 2000; /** A pattern that matched but whose body was rejected before capture. */ export interface KeywordDrop { content: string; pattern: string; personal: boolean; reason: "too-short" | "too-long"; } export interface KeywordScan { hits: KeywordHit[]; dropped: KeywordDrop[]; } /** * Scan user text for memory-capture keyword triggers, reporting both what * would be captured and what matched but was rejected (D67) — a dropped match * that says nothing reads as a broken feature. * Each pattern must have a single capture group whose value becomes the memory body. * Personal patterns (cfg.keywordPersonalPatterns) force the personal scope. * * Personal patterns run first and claim their line: an explicit scope signal * (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope. */ export function scanKeywords(userText: string, cfg: MyOMemoryConfig): KeywordScan { const hits: KeywordHit[] = []; const dropped: KeywordDrop[] = []; if (!cfg.keywordCaptureEnabled) return { hits, dropped }; const lines = userText.split(/\r?\n/); const claimed = new Set<number>(); const scan = (patterns: string[], personal: boolean, skipClaimed: boolean) => { for (const src of patterns) { let re: RegExp; try { re = new RegExp(src, "i"); } catch { continue; } for (let i = 0; i < lines.length; i++) { if (skipClaimed && claimed.has(i)) continue; const m = lines[i].match(re); if (!m || !m[1]) continue; const content = m[1].trim().replace(/[.!?]$/, "").trim(); if (personal) claimed.add(i); if (content.length >= MIN_CAPTURE_CHARS && content.length <= MAX_CAPTURE_CHARS) { hits.push({ content, pattern: src, personal }); break; // one hit per pattern per message } // Rejected: record it once per pattern. The line stays claimed for // personal patterns even here - "记住我:OK" must not fall through to // the generic 记住 and be saved as "我:OK" (D67). if (!dropped.some((d) => d.pattern === src && d.content === content)) { dropped.push({ content, pattern: src, personal, reason: content.length < MIN_CAPTURE_CHARS ? "too-short" : "too-long", }); } break; } } }; scan(cfg.keywordPersonalPatterns, true, false); scan(cfg.keywordPatterns, false, true); if (cfg.logLevel === "debug" && dropped.length > 0) { for (const d of dropped) { console.error( `[open-memex] keyword match not captured (${d.reason === "too-short" ? `body ${d.content.length} chars < ${MIN_CAPTURE_CHARS}` : `body ${d.content.length} chars > ${MAX_CAPTURE_CHARS}`}): "${d.content}"`, ); } } return { hits, dropped }; } /** Capture-only view of {@link scanKeywords} (drops are logged, not returned). */ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] { return scanKeywords(userText, cfg).hits; }