Files
memind/experience-reflect.mjs
T
john 2ec6fdbe11 Implement Experience V1 reflect, retrieval boost, and product metrics.
Add rule-based reflect() to aggregate task outcomes, environment-aware search scoring, experience_saved/recalled events, and structured injection blocks per the V1 schema.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:30:59 +08:00

187 lines
5.9 KiB
JavaScript

import {
mapExperienceRow,
normalizeExperienceRecordInput,
parseExperienceJson,
} from './experience-schema.mjs';
export const DEFAULT_REFLECT_MIN_GROUP_SIZE = 3;
export function problemFingerprint(problem) {
const text = String(problem ?? '')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
if (!text) return '';
return text.slice(0, 120);
}
export function environmentMatchBoost(row, terms) {
if (!terms.length) return 0;
const environment = parseExperienceJson(row.environment_json ?? row.environment, null);
if (!environment) return 0;
const haystack = [
environment.runtime,
...(Array.isArray(environment.components) ? environment.components : []),
environment.region,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (!haystack) return 0;
let boost = 0;
for (const term of terms) {
if (haystack.includes(term)) boost += 0.5;
}
return boost;
}
export function scoreExperienceRow(row, terms, nowMs, halfLifeMs) {
const haystack = [
row.title,
row.body,
row.problem,
row.environment_json ? JSON.stringify(parseExperienceJson(row.environment_json, {})) : '',
]
.filter(Boolean)
.join('\n')
.toLowerCase();
let keywordScore = 0;
for (const term of terms) {
if (haystack.includes(term)) keywordScore += 1;
}
keywordScore += environmentMatchBoost(row, terms);
if (keywordScore <= 0) return { row, score: 0 };
const ageMs = Math.max(0, nowMs - Number(row.updated_at ?? row.updatedAt ?? 0));
const recency = Math.pow(0.5, ageMs / halfLifeMs);
return { row, score: keywordScore * recency };
}
export function groupTaskOutcomesByProblem(rows, minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE) {
const buckets = new Map();
for (const row of rows) {
if (String(row.kind ?? '').toLowerCase() !== 'task_outcome') continue;
if (row.parent_experience_id ?? row.parentExperienceId) continue;
if (String(row.status ?? 'active').toLowerCase() !== 'active') continue;
const fp = problemFingerprint(row.problem || row.title);
if (!fp) continue;
if (!buckets.has(fp)) buckets.set(fp, []);
buckets.get(fp).push(row);
}
return [...buckets.values()].filter((group) => group.length >= minGroupSize);
}
export function findExistingExecutionPattern(rows, fingerprint) {
for (const row of rows) {
if (String(row.kind ?? '').toLowerCase() !== 'execution_pattern') continue;
if (String(row.status ?? 'active').toLowerCase() !== 'active') continue;
const fp = problemFingerprint(row.problem || row.title);
if (fp === fingerprint) return row;
}
return null;
}
export function buildExecutionPatternAggregate(rows) {
const sorted = [...rows].sort(
(a, b) => Number(b.updated_at ?? b.updatedAt ?? 0) - Number(a.updated_at ?? a.updatedAt ?? 0),
);
const problem = sorted[0].problem || sorted[0].title;
const results = sorted.map((row) => row.result).filter(Boolean);
const successCount = results.filter((value) => value === 'success').length;
const result =
successCount >= results.length / 2
? 'success'
: results.every((value) => value === 'failure')
? 'failure'
: 'partial';
const confidences = sorted
.map((row) => (row.confidence == null ? null : Number(row.confidence)))
.filter((value) => Number.isFinite(value));
const confidence =
confidences.length > 0
? Number((confidences.reduce((sum, value) => sum + value, 0) / confidences.length).toFixed(3))
: null;
const components = new Set();
let runtime = null;
const steps = new Set();
let executor = null;
const sources = [];
const seenSourceIds = new Set();
for (const row of sorted) {
const environment = parseExperienceJson(row.environment_json ?? row.environment, null);
if (environment?.runtime) runtime = environment.runtime;
for (const component of environment?.components ?? []) {
if (component) components.add(String(component));
}
const action = parseExperienceJson(row.action_json ?? row.action, null);
if (action?.executor) executor = action.executor;
for (const step of action?.steps ?? []) {
if (step) steps.add(String(step));
}
const evidence = parseExperienceJson(row.evidence_json ?? row.evidence, null);
for (const source of evidence?.sources ?? []) {
const sourceId = String(source?.source_id ?? '').trim();
if (sourceId && seenSourceIds.has(sourceId)) continue;
if (sourceId) seenSourceIds.add(sourceId);
sources.push(source);
}
}
const bodyLines = sorted.slice(0, 5).map((row) => `- ${row.title}: ${row.body}`);
if (sorted.length > 5) {
bodyLines.push(`- … 另有 ${sorted.length - 5} 条同类任务记录`);
}
return normalizeExperienceRecordInput({
kind: 'execution_pattern',
title: String(problem).slice(0, 200),
body: `聚合 ${sorted.length} 次同类任务经验:\n${bodyLines.join('\n')}`,
problem,
result,
confidence,
environment: {
runtime,
components: [...components],
},
action: {
executor,
steps: [...steps],
},
evidence: {
sources,
provenance: {
aggregated_from: sorted.map((row) => row.id),
},
},
tags: ['execution_pattern', 'reflect'],
});
}
export function planExperienceReflection(rows, { minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE } = {}) {
const activeRows = rows.filter((row) => String(row.status ?? 'active').toLowerCase() === 'active');
const groups = groupTaskOutcomesByProblem(activeRows, minGroupSize);
const plans = [];
for (const group of groups) {
const fingerprint = problemFingerprint(group[0].problem || group[0].title);
if (!fingerprint) continue;
if (findExistingExecutionPattern(activeRows, fingerprint)) continue;
plans.push({
fingerprint,
sourceIds: group.map((row) => row.id),
payload: buildExecutionPatternAggregate(group),
});
}
return plans;
}
export function mapReflectResult(savedRow) {
return mapExperienceRow(savedRow);
}