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