295 lines
9.6 KiB
JavaScript
295 lines
9.6 KiB
JavaScript
/**
|
|
* RvfLearningStore - Persistent storage for SONA learning artifacts
|
|
*
|
|
* Stores patterns, LoRA adapters, EWC state, and trajectories in a
|
|
* binary-header JSON-lines file format for fast append and rebuild.
|
|
*
|
|
* File format:
|
|
* 4-byte magic "RVLS" + newline
|
|
* One JSON record per line: {"type":"pattern"|"lora"|"ewc"|"trajectory","data":{...}}
|
|
*
|
|
* @module @claude-flow/memory/rvf-learning-store
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
// ===== Constants =====
|
|
const MAGIC_HEADER = 'RVLS';
|
|
const DEFAULT_DIMENSIONS = 64;
|
|
const DEFAULT_AUTO_PERSIST_MS = 30_000;
|
|
// ===== Helpers =====
|
|
function ensureDirectory(filePath) {
|
|
const dir = path.dirname(filePath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
// ===== RvfLearningStore =====
|
|
/**
|
|
* Persistent store for SONA learning artifacts.
|
|
*
|
|
* Maintains in-memory maps for fast reads and flushes to a JSON-lines
|
|
* file with a binary header on persist(). On initialize(), the file is
|
|
* read line-by-line to rebuild state.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const store = new RvfLearningStore({ storePath: './data/learning.rvls' });
|
|
* await store.initialize();
|
|
*
|
|
* await store.savePatterns([{ id: 'p1', type: 'query_response', ... }]);
|
|
* await store.persist();
|
|
* await store.close();
|
|
* ```
|
|
*/
|
|
export class RvfLearningStore {
|
|
config;
|
|
patterns = new Map();
|
|
loraAdapters = new Map();
|
|
ewcState = null;
|
|
trajectories = [];
|
|
dirty = false;
|
|
initialized = false;
|
|
autoPersistTimer = null;
|
|
constructor(config) {
|
|
this.config = {
|
|
storePath: config.storePath,
|
|
dimensions: config.dimensions ?? DEFAULT_DIMENSIONS,
|
|
autoPersistInterval: config.autoPersistInterval ?? DEFAULT_AUTO_PERSIST_MS,
|
|
verbose: config.verbose ?? false,
|
|
};
|
|
}
|
|
/**
|
|
* Initialize the store by loading any existing data from disk.
|
|
* Creates the parent directory if it does not exist.
|
|
*/
|
|
async initialize() {
|
|
if (this.initialized)
|
|
return;
|
|
ensureDirectory(this.config.storePath);
|
|
if (fs.existsSync(this.config.storePath)) {
|
|
await this.loadFromDisk();
|
|
}
|
|
if (this.config.autoPersistInterval > 0) {
|
|
this.autoPersistTimer = setInterval(() => void this.persist().catch(() => { }), this.config.autoPersistInterval);
|
|
// Allow the process to exit even if the timer is active
|
|
if (this.autoPersistTimer.unref) {
|
|
this.autoPersistTimer.unref();
|
|
}
|
|
}
|
|
this.initialized = true;
|
|
this.log('Store initialized');
|
|
}
|
|
// ===== Pattern operations =====
|
|
/**
|
|
* Save or update patterns. Existing patterns with matching IDs are
|
|
* overwritten; new patterns are added.
|
|
*
|
|
* @returns The number of patterns stored
|
|
*/
|
|
async savePatterns(patterns) {
|
|
this.ensureInitialized();
|
|
let count = 0;
|
|
for (const pattern of patterns) {
|
|
this.patterns.set(pattern.id, { ...pattern });
|
|
count++;
|
|
}
|
|
this.dirty = true;
|
|
return count;
|
|
}
|
|
/** Load all patterns currently held in memory */
|
|
async loadPatterns() {
|
|
this.ensureInitialized();
|
|
return Array.from(this.patterns.values());
|
|
}
|
|
/** Return the number of stored patterns */
|
|
async getPatternCount() {
|
|
this.ensureInitialized();
|
|
return this.patterns.size;
|
|
}
|
|
// ===== LoRA operations =====
|
|
/** Save or update a LoRA adapter record */
|
|
async saveLoraAdapter(record) {
|
|
this.ensureInitialized();
|
|
this.loraAdapters.set(record.id, { ...record });
|
|
this.dirty = true;
|
|
}
|
|
/** Load all LoRA adapter records */
|
|
async loadLoraAdapters() {
|
|
this.ensureInitialized();
|
|
return Array.from(this.loraAdapters.values());
|
|
}
|
|
/** Delete a LoRA adapter by ID */
|
|
async deleteLoraAdapter(id) {
|
|
this.ensureInitialized();
|
|
const existed = this.loraAdapters.delete(id);
|
|
if (existed)
|
|
this.dirty = true;
|
|
return existed;
|
|
}
|
|
// ===== EWC operations =====
|
|
/** Save EWC state (replaces any existing state) */
|
|
async saveEwcState(record) {
|
|
this.ensureInitialized();
|
|
this.ewcState = { ...record };
|
|
this.dirty = true;
|
|
}
|
|
/** Load the EWC state, or null if none has been stored */
|
|
async loadEwcState() {
|
|
this.ensureInitialized();
|
|
return this.ewcState ? { ...this.ewcState } : null;
|
|
}
|
|
// ===== Trajectory operations =====
|
|
/** Append a trajectory record (append-only, never overwritten) */
|
|
async appendTrajectory(record) {
|
|
this.ensureInitialized();
|
|
this.trajectories.push({ ...record });
|
|
this.dirty = true;
|
|
}
|
|
/**
|
|
* Return stored trajectories, newest first.
|
|
* @param limit Maximum number to return (default: all)
|
|
*/
|
|
async getTrajectories(limit) {
|
|
this.ensureInitialized();
|
|
const sorted = [...this.trajectories].reverse();
|
|
return limit !== undefined ? sorted.slice(0, limit) : sorted;
|
|
}
|
|
/** Return the number of stored trajectories */
|
|
async getTrajectoryCount() {
|
|
this.ensureInitialized();
|
|
return this.trajectories.length;
|
|
}
|
|
// ===== Lifecycle =====
|
|
/**
|
|
* Flush all in-memory state to disk. The entire file is rewritten
|
|
* to ensure consistency (patterns may have been updated in-place).
|
|
*/
|
|
async persist() {
|
|
if (!this.dirty)
|
|
return;
|
|
ensureDirectory(this.config.storePath);
|
|
const lines = [MAGIC_HEADER];
|
|
// Patterns
|
|
for (const pattern of this.patterns.values()) {
|
|
lines.push(JSON.stringify({ type: 'pattern', data: pattern }));
|
|
}
|
|
// LoRA adapters
|
|
for (const lora of this.loraAdapters.values()) {
|
|
lines.push(JSON.stringify({ type: 'lora', data: lora }));
|
|
}
|
|
// EWC state
|
|
if (this.ewcState) {
|
|
lines.push(JSON.stringify({ type: 'ewc', data: this.ewcState }));
|
|
}
|
|
// Trajectories
|
|
for (const traj of this.trajectories) {
|
|
lines.push(JSON.stringify({ type: 'trajectory', data: traj }));
|
|
}
|
|
const content = lines.join('\n') + '\n';
|
|
const tmpPath = this.config.storePath + '.tmp';
|
|
await fs.promises.writeFile(tmpPath, content, 'utf-8');
|
|
await fs.promises.rename(tmpPath, this.config.storePath);
|
|
this.dirty = false;
|
|
this.log(`Persisted: ${this.patterns.size} patterns, ${this.loraAdapters.size} LoRA, ${this.trajectories.length} trajectories`);
|
|
}
|
|
/** Persist and release resources */
|
|
async close() {
|
|
if (this.autoPersistTimer) {
|
|
clearInterval(this.autoPersistTimer);
|
|
this.autoPersistTimer = null;
|
|
}
|
|
if (this.dirty) {
|
|
await this.persist();
|
|
}
|
|
this.initialized = false;
|
|
this.log('Store closed');
|
|
}
|
|
// ===== Stats =====
|
|
/** Return summary statistics about the store */
|
|
async getStats() {
|
|
this.ensureInitialized();
|
|
let fileSizeBytes = 0;
|
|
try {
|
|
const stat = await fs.promises.stat(this.config.storePath);
|
|
fileSizeBytes = stat.size;
|
|
}
|
|
catch {
|
|
// File may not exist yet if nothing has been persisted
|
|
}
|
|
return {
|
|
patterns: this.patterns.size,
|
|
loraAdapters: this.loraAdapters.size,
|
|
trajectories: this.trajectories.length,
|
|
hasEwcState: this.ewcState !== null,
|
|
fileSizeBytes,
|
|
};
|
|
}
|
|
// ===== Private =====
|
|
async loadFromDisk() {
|
|
let content;
|
|
try {
|
|
content = await fs.promises.readFile(this.config.storePath, 'utf-8');
|
|
}
|
|
catch {
|
|
return;
|
|
}
|
|
const lines = content.split('\n').filter((l) => l.trim().length > 0);
|
|
if (lines.length === 0)
|
|
return;
|
|
// Verify magic header
|
|
if (lines[0] !== MAGIC_HEADER) {
|
|
this.log(`Warning: invalid magic header "${lines[0]}", expected "${MAGIC_HEADER}"`);
|
|
return;
|
|
}
|
|
let parsed = 0;
|
|
let errors = 0;
|
|
for (let i = 1; i < lines.length; i++) {
|
|
try {
|
|
const record = JSON.parse(lines[i]);
|
|
this.applyRecord(record);
|
|
parsed++;
|
|
}
|
|
catch {
|
|
errors++;
|
|
this.log(`Warning: failed to parse line ${i + 1}`);
|
|
}
|
|
}
|
|
this.log(`Loaded from disk: ${parsed} records, ${errors} errors`);
|
|
}
|
|
applyRecord(record) {
|
|
switch (record.type) {
|
|
case 'pattern': {
|
|
const p = record.data;
|
|
this.patterns.set(p.id, p);
|
|
break;
|
|
}
|
|
case 'lora': {
|
|
const l = record.data;
|
|
this.loraAdapters.set(l.id, l);
|
|
break;
|
|
}
|
|
case 'ewc': {
|
|
this.ewcState = record.data;
|
|
break;
|
|
}
|
|
case 'trajectory': {
|
|
this.trajectories.push(record.data);
|
|
break;
|
|
}
|
|
default:
|
|
this.log(`Warning: unknown record type "${record.type}"`);
|
|
}
|
|
}
|
|
ensureInitialized() {
|
|
if (!this.initialized) {
|
|
throw new Error('RvfLearningStore has not been initialized. Call initialize() first.');
|
|
}
|
|
}
|
|
log(message) {
|
|
if (this.config.verbose) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[RvfLearningStore] ${message}`);
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=rvf-learning-store.js.map
|