Add Experience V1 schema migration and agent-run completion extractor.
Extend h5_experience with structured fields, wire mindspace-agent-runner and agent-run-gateway to persist task_outcome records with provenance, and add local migration and verification scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+64
-69
@@ -1,5 +1,12 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
experienceSearchHaystack,
|
||||
mapExperienceRow,
|
||||
normalizeExperienceRecordInput,
|
||||
serializeExperienceJson,
|
||||
} from './experience-schema.mjs';
|
||||
|
||||
/**
|
||||
* Shared experience store (etat C of the goose scale plan).
|
||||
*
|
||||
@@ -16,58 +23,22 @@ import crypto from 'node:crypto';
|
||||
*/
|
||||
export function createExperienceService(pool, options = {}) {
|
||||
const now = options.now ?? (() => Date.now());
|
||||
// Recency half-life: a record this old contributes half its keyword score.
|
||||
const halfLifeMs = Math.max(
|
||||
60_000,
|
||||
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function rowToExperience(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
kind: row.kind,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
tags: row.tags_json ? safeJsonArray(row.tags_json) : [],
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Keyword overlap × recency decay. Kept separate so the pgvector backend can
|
||||
// replace this with cosine similarity without touching search()'s shape.
|
||||
function rankRows(rows, queryTerms, nowMs) {
|
||||
const terms = queryTerms;
|
||||
return rows
|
||||
.map((row) => {
|
||||
const haystack = `${row.title}\n${row.body}`.toLowerCase();
|
||||
const haystack = experienceSearchHaystack(row);
|
||||
let keywordScore = 0;
|
||||
for (const term of terms) {
|
||||
for (const term of queryTerms) {
|
||||
if (haystack.includes(term)) keywordScore += 1;
|
||||
}
|
||||
const ageMs = Math.max(0, nowMs - Number(row.updated_at));
|
||||
const recency = Math.pow(0.5, ageMs / halfLifeMs);
|
||||
const score = keywordScore * recency;
|
||||
return { row, score };
|
||||
return { row, score: keywordScore * recency };
|
||||
})
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
@@ -89,43 +60,66 @@ export function createExperienceService(pool, options = {}) {
|
||||
* Persist a piece of experience. Returns the stored record.
|
||||
*/
|
||||
async function record(input) {
|
||||
const title = String(input?.title ?? '').trim();
|
||||
const body = String(input?.body ?? '').trim();
|
||||
if (!title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
||||
if (!body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
|
||||
const normalized = normalizeExperienceRecordInput(input);
|
||||
if (!normalized.title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
||||
if (!normalized.body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const ts = now();
|
||||
const scope = String(input?.scope ?? 'global').trim() || 'global';
|
||||
const kind = String(input?.kind ?? 'lesson').trim() || 'lesson';
|
||||
const tags = normalizeTags(input?.tags);
|
||||
const environmentJson = serializeExperienceJson(normalized.environment);
|
||||
const actionJson = serializeExperienceJson(normalized.action);
|
||||
const evidenceJson = serializeExperienceJson(normalized.evidence);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO h5_experience
|
||||
(id, scope, kind, title, body, tags_json, source_session_id, source_user_id,
|
||||
use_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
use_count, problem, environment_json, hypothesis, action_json, result, confidence,
|
||||
evidence_json, parent_experience_id, supersedes_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
scope,
|
||||
kind,
|
||||
title,
|
||||
body,
|
||||
JSON.stringify(tags),
|
||||
input?.sourceSessionId ?? null,
|
||||
input?.sourceUserId ?? null,
|
||||
normalized.scope,
|
||||
normalized.kind,
|
||||
normalized.title,
|
||||
normalized.body,
|
||||
JSON.stringify(normalized.tags),
|
||||
normalized.sourceSessionId,
|
||||
normalized.sourceUserId,
|
||||
normalized.problem,
|
||||
environmentJson,
|
||||
normalized.hypothesis,
|
||||
actionJson,
|
||||
normalized.result,
|
||||
normalized.confidence,
|
||||
evidenceJson,
|
||||
normalized.parentExperienceId,
|
||||
normalized.supersedesId,
|
||||
normalized.status,
|
||||
ts,
|
||||
ts,
|
||||
],
|
||||
);
|
||||
return rowToExperience({
|
||||
|
||||
return mapExperienceRow({
|
||||
id,
|
||||
scope,
|
||||
kind,
|
||||
title,
|
||||
body,
|
||||
tags_json: tags,
|
||||
source_session_id: input?.sourceSessionId ?? null,
|
||||
source_user_id: input?.sourceUserId ?? null,
|
||||
scope: normalized.scope,
|
||||
kind: normalized.kind,
|
||||
title: normalized.title,
|
||||
body: normalized.body,
|
||||
tags_json: JSON.stringify(normalized.tags),
|
||||
source_session_id: normalized.sourceSessionId,
|
||||
source_user_id: normalized.sourceUserId,
|
||||
use_count: 0,
|
||||
problem: normalized.problem,
|
||||
environment_json: environmentJson,
|
||||
hypothesis: normalized.hypothesis,
|
||||
action_json: actionJson,
|
||||
result: normalized.result,
|
||||
confidence: normalized.confidence,
|
||||
evidence_json: evidenceJson,
|
||||
parent_experience_id: normalized.parentExperienceId,
|
||||
supersedes_id: normalized.supersedesId,
|
||||
status: normalized.status,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
@@ -138,20 +132,19 @@ export function createExperienceService(pool, options = {}) {
|
||||
async function search(query, { scope = 'global', limit = 5 } = {}) {
|
||||
const terms = tokenize(query);
|
||||
if (terms.length === 0) return [];
|
||||
// Pull a bounded candidate set by scope+recency, then rank in JS. The
|
||||
// candidate cap keeps this cheap on MySQL; pgvector would push ranking into SQL.
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, scope, kind, title, body, tags_json, source_session_id,
|
||||
source_user_id, use_count, created_at, updated_at
|
||||
source_user_id, use_count, problem, environment_json, hypothesis,
|
||||
action_json, result, confidence, evidence_json, parent_experience_id,
|
||||
supersedes_id, status, created_at, updated_at
|
||||
FROM h5_experience
|
||||
WHERE scope = ?
|
||||
WHERE scope = ? AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 500`,
|
||||
[scope],
|
||||
);
|
||||
const ranked = rankRows(rows, terms, now()).slice(0, Math.max(1, limit));
|
||||
if (ranked.length > 0) {
|
||||
// Best-effort usage bump so popular experience can be surfaced/weighted later.
|
||||
const ids = ranked.map((entry) => entry.row.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await pool
|
||||
@@ -161,7 +154,7 @@ export function createExperienceService(pool, options = {}) {
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
return ranked.map((entry) => rowToExperience(entry.row));
|
||||
return ranked.map((entry) => mapExperienceRow(entry.row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,3 +172,5 @@ export function createExperienceService(pool, options = {}) {
|
||||
function experienceError(message, code) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
export { formatExperienceInjectionBlock, formatExperienceInjectionLine } from './experience-schema.mjs';
|
||||
|
||||
Reference in New Issue
Block a user