2ec6fdbe11
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>
187 lines
5.9 KiB
JavaScript
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);
|
|
}
|