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
text/typescript
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;
}