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} 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 }); }