Files
memind/experience-service.mjs
john 25f8223253 refactor: Business logic and dependencies updates
- 核心服务代码更新 (db, server, auth, proxy)
- Agent 相关模块更新 (mindspace, experience)
- 前端组件和 hooks 更新
- 数据库 schema 更新
- 依赖版本更新
2026-06-27 08:25:02 +08:00

182 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
/**
* Shared experience store (etat C of the goose scale plan).
*
* All goosed Worker instances read/write the same learned experience so that
* capability is not siloed per instance. This first implementation is backed by
* MySQL (`h5_experience`) with a keyword + recency ranking. The retrieval scoring
* is deliberately isolated in `rankRows` so that swapping to PostgreSQL + pgvector
* later only replaces the query/scoring layer, not the calling contract
* (record / search / reflect).
*
* @param {import('mysql2/promise').Pool} pool
* @param {object} [options]
* @param {() => number} [options.now] - injectable clock for tests
*/
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();
let keywordScore = 0;
for (const term of terms) {
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 };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
}
function tokenize(query) {
return [
...new Set(
String(query ?? '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.map((t) => t.trim())
.filter((t) => t.length >= 2),
),
];
}
/**
* 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 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);
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, ?, ?)`,
[
id,
scope,
kind,
title,
body,
JSON.stringify(tags),
input?.sourceSessionId ?? null,
input?.sourceUserId ?? null,
ts,
ts,
],
);
return rowToExperience({
id,
scope,
kind,
title,
body,
tags_json: tags,
source_session_id: input?.sourceSessionId ?? null,
source_user_id: input?.sourceUserId ?? null,
use_count: 0,
created_at: ts,
updated_at: ts,
});
}
/**
* Retrieve the most relevant experience for a query within a scope.
* @returns {Promise<Array>} ranked experiences (best first), max `limit`.
*/
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
FROM h5_experience
WHERE scope = ?
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
.query(
`UPDATE h5_experience SET use_count = use_count + 1 WHERE id IN (${placeholders})`,
ids,
)
.catch(() => {});
}
return ranked.map((entry) => rowToExperience(entry.row));
}
/**
* Placeholder for the reflection pass that distills raw session traces into
* durable lessons. Wired later once the record() trigger points are defined;
* kept here so the calling contract is stable.
*/
async function reflect() {
return { ok: false, reason: 'not_implemented' };
}
return { record, search, reflect };
}
function experienceError(message, code) {
return Object.assign(new Error(message), { code });
}