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