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:
+91
-47
@@ -1,5 +1,12 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
experienceSearchHaystack,
|
||||
mapExperienceRow,
|
||||
normalizeExperienceRecordInput,
|
||||
serializeExperienceJson,
|
||||
} from './experience-schema.mjs';
|
||||
|
||||
/**
|
||||
* PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
|
||||
*
|
||||
@@ -51,10 +58,35 @@ export async function createPgExperienceService(options = {}) {
|
||||
source_user_id TEXT,
|
||||
use_count BIGINT NOT NULL DEFAULT 0,
|
||||
embedding vector(${embeddingDim}),
|
||||
problem TEXT,
|
||||
environment_json JSONB,
|
||||
hypothesis TEXT,
|
||||
action_json JSONB,
|
||||
result TEXT,
|
||||
confidence DOUBLE PRECISION,
|
||||
evidence_json JSONB,
|
||||
parent_experience_id UUID,
|
||||
supersedes_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
`);
|
||||
const pgExperienceColumns = [
|
||||
['problem', 'TEXT'],
|
||||
['environment_json', 'JSONB'],
|
||||
['hypothesis', 'TEXT'],
|
||||
['action_json', 'JSONB'],
|
||||
['result', 'TEXT'],
|
||||
['confidence', 'DOUBLE PRECISION'],
|
||||
['evidence_json', 'JSONB'],
|
||||
['parent_experience_id', 'UUID'],
|
||||
['supersedes_id', 'UUID'],
|
||||
["status", "TEXT NOT NULL DEFAULT 'active'"],
|
||||
];
|
||||
for (const [column, definition] of pgExperienceColumns) {
|
||||
await pool.query(`ALTER TABLE h5_experience ADD COLUMN IF NOT EXISTS ${column} ${definition}`);
|
||||
}
|
||||
await pool.query(
|
||||
'CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)',
|
||||
);
|
||||
@@ -66,80 +98,88 @@ export async function createPgExperienceService(options = {}) {
|
||||
// ivfflat needs the extension + may warn on empty table; non-fatal.
|
||||
});
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function toVectorLiteral(vec) {
|
||||
// pgvector accepts a string like '[0.1,0.2,...]'
|
||||
return `[${vec.map((n) => Number(n)).join(',')}]`;
|
||||
}
|
||||
|
||||
function rowToExperience(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
kind: row.kind,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
tags: Array.isArray(row.tags) ? 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),
|
||||
};
|
||||
return mapExperienceRow({
|
||||
...row,
|
||||
tags_json: row.tags,
|
||||
});
|
||||
}
|
||||
|
||||
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 = normalized.environment;
|
||||
const actionJson = normalized.action;
|
||||
const evidenceJson = normalized.evidence;
|
||||
let embedding = null;
|
||||
if (embed) {
|
||||
try {
|
||||
const vec = await embed(`${title}\n${body}`);
|
||||
const vec = await embed(`${normalized.title}\n${normalized.body}`);
|
||||
if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec);
|
||||
} catch {
|
||||
embedding = null; // embedding failure must not block recording
|
||||
embedding = null;
|
||||
}
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO h5_experience
|
||||
(id, scope, kind, title, body, tags, source_session_id, source_user_id,
|
||||
use_count, embedding, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11)`,
|
||||
use_count, embedding, problem, environment_json, hypothesis, action_json,
|
||||
result, confidence, evidence_json, parent_experience_id, supersedes_id,
|
||||
status, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11::jsonb,$12,$13::jsonb,
|
||||
$14,$15,$16::jsonb,$17::uuid,$18::uuid,$19,$20,$21)`,
|
||||
[
|
||||
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,
|
||||
embedding,
|
||||
normalized.problem,
|
||||
environmentJson == null ? null : JSON.stringify(environmentJson),
|
||||
normalized.hypothesis,
|
||||
actionJson == null ? null : JSON.stringify(actionJson),
|
||||
normalized.result,
|
||||
normalized.confidence,
|
||||
evidenceJson == null ? null : JSON.stringify(evidenceJson),
|
||||
normalized.parentExperienceId,
|
||||
normalized.supersedesId,
|
||||
normalized.status,
|
||||
ts,
|
||||
ts,
|
||||
],
|
||||
);
|
||||
return rowToExperience({
|
||||
id,
|
||||
scope,
|
||||
kind,
|
||||
title,
|
||||
body,
|
||||
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: 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,
|
||||
});
|
||||
@@ -157,9 +197,11 @@ export async function createPgExperienceService(options = {}) {
|
||||
if (Array.isArray(vec) && vec.length === embeddingDim) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, scope, kind, title, body, tags, 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 = $1 AND embedding IS NOT NULL
|
||||
WHERE scope = $1 AND status = 'active' AND embedding IS NOT NULL
|
||||
ORDER BY embedding <=> $2::vector, updated_at DESC
|
||||
LIMIT $3`,
|
||||
[scope, toVectorLiteral(vec), max],
|
||||
@@ -181,9 +223,11 @@ export async function createPgExperienceService(options = {}) {
|
||||
const params = [scope, ...terms.map((t) => `%${t}%`), max];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, scope, kind, title, body, tags, 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 = $1 AND (${likeClauses.join(' OR ')})
|
||||
WHERE scope = $1 AND status = 'active' AND (${likeClauses.join(' OR ')})
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $${terms.length + 2}`,
|
||||
params,
|
||||
|
||||
Reference in New Issue
Block a user