You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

282 lines
14 KiB

'use strict';
// memory.js — слой памяти dsh-memory: Entry-модель, load/save/query.
// Формат Entry: id, track, scope, workspaceKey, key, text, importance,
// tags[], version, createdAt, updatedAt, lastRecalled, recallCount, bytes.
// State: {version:2, updatedAt, entries:[...], chats:{...}}.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ALWAYS_LIMIT = 10;
const TAGS_LIMIT = 5;
const TAG_LEN_LIMIT = 32;
const DIRECTIVES_PER_CHAT = 5;
function nowIso() { return new Date().toISOString(); }
function newId() { return crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'); }
function byteLen(s) { return Buffer.byteLength(String(s || ''), 'utf8'); }
function emptyState() {
return { version: 2, updatedAt: nowIso(), entries: [], chats: {} };
}
function stateFile(memDir) { return path.join(memDir, 'state.json'); }
function templateFile(memDir) { return path.join(memDir, 'state.template.json'); }
function loadState(memDir) {
const f = stateFile(memDir);
let raw = null;
try { raw = fs.readFileSync(f, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
if (raw === null) {
const t = templateFile(memDir);
let tpl = null;
try { tpl = fs.readFileSync(t, 'utf8'); } catch (e) { tpl = null; }
if (tpl !== null) {
const parsed = JSON.parse(tpl);
fs.mkdirSync(memDir, { recursive: true });
fs.writeFileSync(f, JSON.stringify(parsed, null, 2), 'utf8');
return parsed;
}
const s = emptyState();
fs.mkdirSync(memDir, { recursive: true });
fs.writeFileSync(f, JSON.stringify(s, null, 2), 'utf8');
return s;
}
const s = JSON.parse(raw);
return migrateIfNeeded(s);
}
function migrateIfNeeded(s) {
if (s.version >= 2) { if (!Array.isArray(s.entries)) s.entries = []; if (!s.chats) s.chats = {}; return s; }
const next = emptyState();
if (Array.isArray(s.skills)) {
for (const sk of s.skills) {
next.entries.push({
id: newId(), track: 'agent', scope: 'user-global', workspaceKey: '',
key: 'protocol_' + String(sk.id || 'x').replace(/[^a-z0-9_]/gi, ''),
text: String(sk.content || ''), importance: 'always',
tags: ['protocol'], version: 1, createdAt: nowIso(), updatedAt: nowIso(),
lastRecalled: null, recallCount: 0, bytes: byteLen(sk.content)
});
}
}
if (s.chats && typeof s.chats === 'object') next.chats = s.chats;
return next;
}
function saveState(memDir, s) {
s.updatedAt = nowIso();
const f = stateFile(memDir);
const tmp = f + '.tmp';
fs.mkdirSync(memDir, { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), 'utf8');
fs.renameSync(tmp, f);
return s;
}
function preview(text, n) { const s = String(text || ''); return s.length > n ? s.slice(0, n) + '...' : s; }
function rank(a, b) {
if (b.recallCount !== a.recallCount) return b.recallCount - a.recallCount;
return String(b.updatedAt).localeCompare(String(a.updatedAt));
}
function matchFilters(e, f) {
if (f.state && f.state !== "all") { if ((e.state || null) !== f.state) return false; }
else if (!f.state) { if (e.state === "archive") return false; }
if (f.track && e.track !== f.track) return false;
if (f.scope && e.scope !== f.scope) return false;
if (f.importance && e.importance !== f.importance) return false;
if (f.workspaceKey !== undefined && e.workspaceKey !== f.workspaceKey) return false;
if (f.since && String(e.updatedAt) < String(f.since)) return false;
return true;
}
function opList(state, args) {
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500);
const pv = Math.min(Math.max(1, Number(args.preview) || 200), 800);
const items = state.entries.filter(function (e) { return matchFilters(e, args || {}); }).sort(rank).slice(0, limit);
let totalBytes = 0;
for (const e of items) totalBytes += e.bytes || 0;
return {
items: items.map(function (e) { return { id: e.id, track: e.track, scope: e.scope, key: e.key, importance: e.importance, state: e.state || null, tags: e.tags || [], bytes: e.bytes, updatedAt: e.updatedAt, recallCount: e.recallCount, preview: preview(e.text, pv) }; }),
totalBytes: totalBytes, matched: items.length, previewChars: pv
};
}
function opGet(state, args) {
const ids = Array.isArray(args.ids) ? args.ids.map(String) : [];
if (!ids.length) throw new Error('ids required');
const found = [];
let totalBytes = 0;
for (const e of state.entries) {
if (ids.indexOf(e.id) !== -1) { found.push(e); totalBytes += e.bytes || 0; }
}
for (const e of found) { e.lastRecalled = Date.now(); e.recallCount = (e.recallCount || 0) + 1; }
return { items: found.map(function (e) { return { id: e.id, key: e.key, track: e.track, scope: e.scope, importance: e.importance, tags: e.tags || [], text: e.text, bytes: e.bytes, version: e.version }; }), totalBytes: totalBytes, requested: ids.length, found: found.length };
}
function opRecent(state, args) {
const since = args.since ? String(args.since) : new Date(Date.now() - 2 * 3600 * 1000).toISOString();
return opList(state, { since: since, limit: args.limit || 50 });
}
function opUnread(state, args) {
const chatId = String(args.chatId || '');
const chat = chatId && state.chats ? state.chats[chatId] : null;
const since = chat && chat.lastInjectAt ? String(chat.lastInjectAt) : '1970-01-01T00:00:00.000Z';
return opList(state, { since: since, limit: args.limit || 100 });
}
function opSearch(state, args) {
const q = String(args.query || '').toLowerCase();
if (!q) throw new Error('query required');
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 200);
const pv = Math.min(Math.max(1, Number(args.preview) || 200), 800);
const hits = state.entries.filter(function (e) {
if (!matchFilters(e, args || {})) return false;
const t = (String(e.text) + ' ' + String(e.key) + ' ' + (e.tags || []).join(' ')).toLowerCase();
return t.indexOf(q) !== -1;
}).sort(rank).slice(0, limit);
let totalBytes = 0;
for (const e of hits) totalBytes += e.bytes || 0;
return { items: hits.map(function (e) { return { id: e.id, key: e.key, track: e.track, importance: e.importance, state: e.state || null, tags: e.tags || [], bytes: e.bytes, preview: preview(e.text, pv) }; }), totalBytes: totalBytes, matched: hits.length };
}
function opBudgets(state) {
const out = [];
const groups = {};
for (const e of state.entries) {
const k = e.track + '|' + e.scope;
if (!groups[k]) groups[k] = { track: e.track, scope: e.scope, used: 0, count: 0 };
groups[k].used += e.bytes || 0;
groups[k].count++;
}
for (const k in groups) out.push(groups[k]);
const alwaysCount = state.entries.filter(function (e) { return e.importance === 'always'; }).length;
return { groups: out, alwaysCount: alwaysCount, alwaysLimit: ALWAYS_LIMIT, totalEntries: state.entries.length };
}
function normWorkspace(w) { return String(w || '').replace(/\\/g, '/').toLowerCase(); }
function validateEntry(e) {
if (!e.track || (e.track !== 'user' && e.track !== 'agent')) throw new Error('track user or agent');
if (!e.scope || (e.scope !== 'user-global' && e.scope !== 'workspace')) throw new Error('scope user-global or workspace');
if (!e.key || !/^[a-z0-9_]{1,64}$/.test(String(e.key))) throw new Error('key snake_case max 64');
if (typeof e.text !== 'string' || !e.text) throw new Error('text required');
if (e.importance !== 'always' && e.importance !== 'called') throw new Error('importance always or called');
if (e.state !== undefined && e.state !== 'active' && e.state !== 'done' && e.state !== 'archive') throw new Error('state active|done|archive');
if (Array.isArray(e.tags)) {
if (e.tags.length > TAGS_LIMIT) throw new Error('tags max ' + TAGS_LIMIT);
for (const t of e.tags) if (String(t).length > TAG_LEN_LIMIT) throw new Error('tag too long');
}
}
function opWrite(state, args) {
const op = String(args.op || 'add');
if (op === 'add') {
const e = args.entry || {};
validateEntry(e);
const alwaysCount = state.entries.filter(function (x) { return x.importance === 'always'; }).length;
if (e.importance === 'always' && alwaysCount >= ALWAYS_LIMIT) throw new Error('E_TOO_MANY_ALWAYS: limit ' + ALWAYS_LIMIT);
if (String(e.key || '').indexOf('directive_') === 0) {
const dirCount = state.entries.filter(function (x) { return String(x.key).indexOf('directive_') === 0 && x.state === 'active' && (x.chatId || '') === (e.chatId || ''); }).length;
if (dirCount >= DIRECTIVES_PER_CHAT) throw new Error('E_TOO_MANY_DIRECTIVES: limit ' + DIRECTIVES_PER_CHAT + ' per chat');
}
const entry = {
id: newId(), track: e.track, scope: e.scope, workspaceKey: String(e.workspaceKey || ''),
key: e.key, text: e.text, importance: e.importance, tags: Array.isArray(e.tags) ? e.tags.slice(0, TAGS_LIMIT) : [],
state: e.state || null, chatId: e.chatId || '',
version: 1, createdAt: nowIso(), updatedAt: nowIso(),
lastRecalled: null, recallCount: 0, bytes: byteLen(e.text)
};
state.entries.push(entry);
return { ok: true, op: 'add', id: entry.id, version: 1 };
}
if (op === 'replace') {
const id = String(args.id || '');
const e = state.entries.find(function (x) { return x.id === id; });
if (!e) throw new Error('ENTRY_NOT_FOUND');
if (typeof args.text === 'string' && args.text) { e.text = args.text; e.bytes = byteLen(args.text); }
if (Array.isArray(args.tags)) e.tags = args.tags.slice(0, TAGS_LIMIT);
if (args.importance === 'always' || args.importance === 'called') {
if (args.importance === 'always' && e.importance !== 'always') {
const alwaysCount = state.entries.filter(function (x) { return x.importance === 'always'; }).length;
if (alwaysCount >= ALWAYS_LIMIT) throw new Error('E_TOO_MANY_ALWAYS: limit ' + ALWAYS_LIMIT);
}
e.importance = args.importance;
}
if (args.state === 'active' || args.state === 'done' || args.state === 'archive') {
// replace checks directive limit
if (args.state === 'active' && e.state !== 'active' && String(e.key).indexOf('directive_') === 0) {
const dirCount = state.entries.filter(function (x) { return String(x.key).indexOf('directive_') === 0 && x.state === 'active' && (x.chatId || '') === (e.chatId || '') && x.id !== e.id; }).length;
if (dirCount >= DIRECTIVES_PER_CHAT) throw new Error('E_TOO_MANY_DIRECTIVES: limit ' + DIRECTIVES_PER_CHAT + ' per chat');
}
e.state = args.state;
}
if (typeof args.chatId === 'string') e.chatId = args.chatId;
e.version = (e.version || 1) + 1;
e.updatedAt = nowIso();
return { ok: true, op: 'replace', id: e.id, version: e.version };
}
if (op === 'remove') {
const id = String(args.id || '');
const i = state.entries.findIndex(function (x) { return x.id === id; });
if (i === -1) throw new Error('ENTRY_NOT_FOUND');
state.entries.splice(i, 1);
return { ok: true, op: 'remove', id: id };
}
throw new Error('op add|replace|remove');
}
function opDirectives(state, args) {
const chatId = String(args.chatId || '');
const stateFilter = args.state ? String(args.state) : 'active';
const pv = Math.min(Math.max(1, Number(args.preview) || 200), 800);
const hits = state.entries.filter(function (e) {
if (String(e.key).indexOf('directive_') !== 0) return false;
if (stateFilter !== 'all' && e.state !== stateFilter) return false;
if (chatId && e.chatId && e.chatId !== chatId) return false;
return true;
}).sort(rank);
let totalBytes = 0;
for (const e of hits) totalBytes += e.bytes || 0;
return { items: hits.map(function (e) { return { id: e.id, key: e.key, state: e.state, chatId: e.chatId || '', importance: e.importance, bytes: e.bytes, updatedAt: e.updatedAt, preview: preview(e.text, pv) }; }), totalBytes: totalBytes, matched: hits.length, stateFilter: stateFilter };
}
function query(state, args) {
const op = String(args.op || 'list');
if (op === 'list') return opList(state, args);
if (op === 'add' || op === 'replace' || op === 'remove') return opWrite(state, Object.assign({}, args, { op: op }));
if (op === 'directives') return opDirectives(state, args);
if (op === 'get') return opGet(state, args);
if (op === 'recent') return opRecent(state, args);
if (op === 'unread') return opUnread(state, args);
if (op === 'search') return opSearch(state, args);
if (op === 'budgets') return opBudgets(state);
if (op === 'write') return opWrite(state, Object.assign({}, args, { op: args.subop || 'add' }));
// note: add/replace/remove handled above
throw new Error('unknown op: ' + op);
}
function getInjection(state, chatId, workspaceKey) {
const entries = [];
for (const e of state.entries) {
if (e.key && e.key.indexOf('directive_') === 0 && e.scope === 'workspace' && e.state === 'active') {
var ew = normWorkspace(e.workspaceKey); var cw = normWorkspace(workspaceKey); if (ew && cw && ew !== cw) continue;
if (!chatId || !e.chatId || e.chatId === chatId) { entries.push(e); }
continue;
}
if (e.importance !== 'always') continue;
if (e.track === 'agent' && e.scope === 'user-global') { entries.push(e); continue; }
}
const texts = entries.map(function (e) { return String(e.text); });
const fpBase = entries.map(function (e) { return e.id + ':' + (e.version || 1); }).sort().join('|');
const fp = crypto.createHash('sha256').update(fpBase).digest('hex').slice(0, 16);
const block = texts.length ? '[DSHMEM v2]\n' + texts.join('\n\n') : '';
if (chatId && state.chats && state.chats[chatId]) state.chats[chatId].lastInjectAt = nowIso();
return { block: block, fingerprint: fp, count: entries.length, bytes: byteLen(block) };
}
module.exports = { loadState, saveState, query, getInjection, emptyState, stateFile, templateFile, newId, byteLen, ALWAYS_LIMIT, TAGS_LIMIT, DIRECTIVES_PER_CHAT };