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>
231 lines
7.8 KiB
JavaScript
231 lines
7.8 KiB
JavaScript
export const EXPERIENCE_RESULTS = Object.freeze(['success', 'partial', 'failure', 'unknown']);
|
|
export const EXPERIENCE_STATUSES = Object.freeze(['active', 'archived', 'deleted']);
|
|
export const EXPERIENCE_KINDS = Object.freeze([
|
|
'lesson',
|
|
'task_outcome',
|
|
'execution_pattern',
|
|
'skill_candidate',
|
|
]);
|
|
|
|
export const EXPERIENCE_V1_MYSQL_DDL = Object.freeze([
|
|
'problem VARCHAR(512) NULL',
|
|
'environment_json JSON NULL',
|
|
'hypothesis VARCHAR(512) NULL',
|
|
'action_json JSON NULL',
|
|
"result ENUM('success','partial','failure','unknown') NULL",
|
|
'confidence DECIMAL(4,3) NULL',
|
|
'evidence_json JSON NULL',
|
|
'parent_experience_id CHAR(36) NULL',
|
|
'supersedes_id CHAR(36) NULL',
|
|
"status ENUM('active','archived','deleted') NOT NULL DEFAULT 'active'",
|
|
]);
|
|
|
|
function nonEmptyString(value, maxLen = null) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) return null;
|
|
return maxLen ? text.slice(0, maxLen) : text;
|
|
}
|
|
|
|
function normalizeConfidence(value) {
|
|
if (value == null || value === '') return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
return Math.min(1, Math.max(0, parsed));
|
|
}
|
|
|
|
function normalizeResult(value) {
|
|
const text = String(value ?? '').trim().toLowerCase();
|
|
if (!text) return null;
|
|
return EXPERIENCE_RESULTS.includes(text) ? text : null;
|
|
}
|
|
|
|
function normalizeStatus(value) {
|
|
const text = String(value ?? 'active').trim().toLowerCase();
|
|
return EXPERIENCE_STATUSES.includes(text) ? text : 'active';
|
|
}
|
|
|
|
function normalizeKind(value) {
|
|
const text = String(value ?? 'lesson').trim().toLowerCase();
|
|
return EXPERIENCE_KINDS.includes(text) ? text : 'lesson';
|
|
}
|
|
|
|
export function serializeExperienceJson(value) {
|
|
if (value == null) return null;
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
const trimmed = String(value).trim();
|
|
if (!trimmed) return null;
|
|
JSON.parse(trimmed);
|
|
return trimmed;
|
|
}
|
|
|
|
export function parseExperienceJson(value, fallback = null) {
|
|
if (value == null) return fallback;
|
|
if (typeof value === 'object') return value;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize caller input into stable DB-bound fields. Title/body remain required
|
|
* at the service layer; structured fields are optional.
|
|
*/
|
|
export function normalizeExperienceRecordInput(input = {}) {
|
|
return {
|
|
scope: nonEmptyString(input.scope, 64) ?? 'global',
|
|
kind: normalizeKind(input.kind),
|
|
title: nonEmptyString(input.title, 255),
|
|
body: nonEmptyString(input.body),
|
|
tags: Array.isArray(input.tags)
|
|
? [...new Set(input.tags.map((tag) => String(tag).trim()).filter(Boolean))]
|
|
: [],
|
|
sourceSessionId: nonEmptyString(input.sourceSessionId, 128),
|
|
sourceUserId: nonEmptyString(input.sourceUserId, 36),
|
|
problem: nonEmptyString(input.problem, 512),
|
|
environment: parseExperienceJson(input.environment ?? input.environmentJson, null),
|
|
hypothesis: nonEmptyString(input.hypothesis, 512),
|
|
action: parseExperienceJson(input.action ?? input.actionJson, null),
|
|
result: normalizeResult(input.result),
|
|
confidence: normalizeConfidence(input.confidence),
|
|
evidence: parseExperienceJson(input.evidence ?? input.evidenceJson, null),
|
|
parentExperienceId: nonEmptyString(input.parentExperienceId, 36),
|
|
supersedesId: nonEmptyString(input.supersedesId, 36),
|
|
status: normalizeStatus(input.status),
|
|
};
|
|
}
|
|
|
|
export function mapExperienceRow(row) {
|
|
return {
|
|
id: row.id,
|
|
scope: row.scope,
|
|
kind: row.kind,
|
|
title: row.title,
|
|
body: row.body,
|
|
tags: parseExperienceJson(row.tags_json ?? row.tags, []) ?? [],
|
|
sourceSessionId: row.source_session_id ?? null,
|
|
sourceUserId: row.source_user_id ?? null,
|
|
useCount: Number(row.use_count ?? 0),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
problem: row.problem ?? null,
|
|
environment: parseExperienceJson(row.environment_json, null),
|
|
hypothesis: row.hypothesis ?? null,
|
|
action: parseExperienceJson(row.action_json, null),
|
|
result: row.result ?? null,
|
|
confidence: row.confidence == null ? null : Number(row.confidence),
|
|
evidence: parseExperienceJson(row.evidence_json, null),
|
|
parentExperienceId: row.parent_experience_id ?? null,
|
|
supersedesId: row.supersedes_id ?? null,
|
|
status: row.status ?? 'active',
|
|
};
|
|
}
|
|
|
|
export function experienceSearchHaystack(row) {
|
|
const environmentText = row.environment_json
|
|
? JSON.stringify(parseExperienceJson(row.environment_json, {}))
|
|
: '';
|
|
return [
|
|
row.title,
|
|
row.body,
|
|
row.problem,
|
|
environmentText,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.toLowerCase();
|
|
}
|
|
|
|
export function formatExperienceMonth(timestampMs) {
|
|
const ts = Number(timestampMs);
|
|
if (!Number.isFinite(ts) || ts <= 0) return 'unknown';
|
|
const date = new Date(ts);
|
|
const year = date.getUTCFullYear();
|
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
return `${year}-${month}`;
|
|
}
|
|
|
|
export function formatExperienceActionSummary(action) {
|
|
if (!action || typeof action !== 'object') return '';
|
|
const steps = Array.isArray(action.steps) ? action.steps.filter(Boolean) : [];
|
|
if (steps.length > 0) return steps.slice(0, 3).join(' → ');
|
|
const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter(Boolean) : [];
|
|
if (artifacts.length > 0) return `artifacts: ${artifacts.slice(0, 3).join(', ')}`;
|
|
return action.executor ? String(action.executor) : '';
|
|
}
|
|
|
|
export function formatExperienceInjectionBlock(hits = []) {
|
|
if (!Array.isArray(hits) || hits.length === 0) return '';
|
|
const lines = hits.map((hit) => formatExperienceInjectionLine(hit));
|
|
return `# 相关经验(供参考,来自历史任务)\n${lines.join('\n')}`;
|
|
}
|
|
|
|
export function formatExperienceInjectionLine(hit) {
|
|
const month = formatExperienceMonth(hit.createdAt);
|
|
const headline = hit.problem || hit.title;
|
|
const resultPart = hit.result
|
|
? ` (${hit.result}${hit.confidence == null ? '' : `, conf=${hit.confidence}`})`
|
|
: '';
|
|
const parts = [`- [${month}] ${headline}${resultPart}`];
|
|
|
|
const runtime = hit.environment?.runtime;
|
|
const components = Array.isArray(hit.environment?.components)
|
|
? hit.environment.components.filter(Boolean).join(' + ')
|
|
: '';
|
|
if (runtime || components) {
|
|
parts.push(` 环境: ${[runtime, components].filter(Boolean).join(' + ')}`);
|
|
}
|
|
|
|
const actionSummary = formatExperienceActionSummary(hit.action);
|
|
if (actionSummary) parts.push(` 处理: ${actionSummary}`);
|
|
|
|
parts.push(` 摘要: ${hit.body}`);
|
|
return parts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build a structured task_outcome payload for MindSpace agent jobs.
|
|
*/
|
|
export function buildMindspaceJobExperiencePayload({ claim, sessionId, parsed, jobId = null } = {}) {
|
|
const title = String(parsed?.title ?? claim?.instruction ?? '').trim().slice(0, 200);
|
|
const summary = String(parsed?.summary ?? '').trim();
|
|
if (!title || !summary) return null;
|
|
|
|
const runRef = jobId ? String(jobId) : null;
|
|
return normalizeExperienceRecordInput({
|
|
kind: 'task_outcome',
|
|
title,
|
|
body: summary,
|
|
problem: claim?.instruction ?? title,
|
|
result: parsed?.status === 'failed' ? 'failure' : 'success',
|
|
sourceSessionId: sessionId ?? null,
|
|
sourceUserId: claim?.userId ?? null,
|
|
environment: {
|
|
runtime: 'mindspace-agent-job',
|
|
components: ['goose', 'portal'],
|
|
},
|
|
action: {
|
|
executor: 'goose',
|
|
steps: ['agent_job_complete'],
|
|
artifacts: Array.isArray(parsed?.sourceAssetIds) ? parsed.sourceAssetIds : [],
|
|
},
|
|
evidence: {
|
|
sources: runRef
|
|
? [{
|
|
source_id: `job:${runRef}`,
|
|
source_type: 'agent_job',
|
|
actor: 'goose',
|
|
timestamp_ms: Date.now(),
|
|
modality: 'task_result',
|
|
}]
|
|
: [],
|
|
provenance: {
|
|
session_id: sessionId ?? null,
|
|
user_id: claim?.userId ?? null,
|
|
job_id: runRef,
|
|
},
|
|
},
|
|
});
|
|
}
|