lib/mate-knowledge-sync.js
Rama main · réplica de lectura del repositorio original.
// Compatibility write-through bridge for Mate durable memory and knowledge.
//
// The startup importer alone goes stale the moment a Mate writes a digest or a
// user edits a knowledge file. This module keeps the Knowledge scope current
// after every known mutation, without changing how the legacy files are
// written. Legacy files stay authoritative and rollback-compatible; the new
// backend is a continuously reconciled mirror.
//
// Reconciliation is source-level, not event-level. A caller says "this source
// changed" and this module re-reads that one file and makes the scope match it:
// creating, revising, reviving, or tombstoning as required. That makes every
// call idempotent and makes deletions and rewrites work without the call site
// having to describe what changed.
//
// Identity is never mirrored. AGENTS.md, legacy CLAUDE.md, base-template.md, identity-backup.md,
// and identity-history.jsonl define who a Mate is and remain canonical files.
// Sticky notes are a separate attention layer and are excluded too.
var fs = require("fs");
var path = require("path");
var crypto = require("crypto");
var config = require("./config");
var knowledgeImport = require("./knowledge-import");
var SOURCE_NAMESPACE = "mate-knowledge";
var SOURCE_VERSION = 3;
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
// Identity and template artifacts. Never mirrored, at any version.
var IDENTITY_FILES = ["base-template.md", "identity-backup.md", "identity-history.jsonl"];
// Sticky notes remain the separate attention layer, with their own store.
var EXCLUDED_FILES = ["sticky-notes.md"];
// Line-addressed journals get one record per line, keyed by line content.
var JOURNAL_KINDS = {
"session-digests.jsonl": "session-digest",
"user-observations.jsonl": "user-observation",
};
function matesHome() {
return path.resolve(path.join(config.CONFIG_DIR, "mates"));
}
// Derived from the Mate storage root path alone, so the startup importer and
// the live bridge always agree on a scope without needing the same context.
function ownerKeyForRoot(matesRoot) {
var resolved = path.resolve(matesRoot);
var parent = path.dirname(resolved);
var name = path.basename(resolved);
if (parent === matesHome() && name.indexOf("mate_") !== 0) return "u-" + name;
return "h-" + crypto.createHash("sha256").update(resolved).digest("hex").substring(0, 16);
}
function scopeIdFor(ownerKey, mateId) {
return "mate/" + ownerKey + "/" + mateId;
}
// A Mate project's cwd is its Mate directory, so every call site can pass what
// it already has.
function scopeForMateDir(mateDir) {
var resolved = path.resolve(mateDir);
var mateId = path.basename(resolved);
if (mateId.indexOf("mate_") !== 0) return null;
var matesRoot = path.dirname(resolved);
var ownerKey = ownerKeyForRoot(matesRoot);
var parent = path.dirname(path.resolve(matesRoot));
return {
mateId: mateId,
mateDir: resolved,
matesRoot: matesRoot,
ownerKey: ownerKey,
userId: parent === matesHome() ? path.basename(matesRoot) : null,
scopeId: scopeIdFor(ownerKey, mateId),
};
}
function sourceKindFor(name) {
if (IDENTITY_FILES.indexOf(name) !== -1) return null;
if (EXCLUDED_FILES.indexOf(name) !== -1) return null;
if (name === "memory-summary.md") return "memory-summary";
if (JOURNAL_KINDS[name]) return JOURNAL_KINDS[name];
if (/\.md$/.test(name)) return "knowledge-file";
if (/\.jsonl$/.test(name)) return "knowledge-journal";
return null;
}
function safeSourceName(value) {
var name = String(value || "").replace(/\\/g, "/").replace(/^\.\//, ""); var parts = name.split("/");
if (!name || name[0] === "/" || name.length > 500 || parts.length > 20) return null;
if (parts.some(function (part) { return !part || part === "." || part === ".." || part.length > 120; })) return null;
return parts.join("/");
}
function isJournal(name) {
return !!JOURNAL_KINDS[name] || /\.jsonl$/.test(name);
}
function normalizeActor(actor) {
var source = actor || {};
var type = ["user", "agent", "system", "migration"].indexOf(source.type) !== -1 ? source.type : "system";
return {
type: type,
userId: source.userId ? String(source.userId).substring(0, 120) : null,
displayName: source.displayName ? String(source.displayName).substring(0, 120) : null,
sessionKey: source.sessionKey ? String(source.sessionKey).substring(0, 200) : null,
vendor: source.vendor ? String(source.vendor).substring(0, 40) : null,
};
}
// Relative to the Mate directory. The scope already identifies the owner and
// the Mate, so an absolute filesystem path would add nothing but exposure.
function relativeSource(name) {
return "knowledge/" + name;
}
function provenance(scope, name, extra) {
var base = {
namespace: SOURCE_NAMESPACE,
sourceVersion: SOURCE_VERSION,
relPath: relativeSource(name),
fileName: name,
ownerKey: scope.ownerKey,
userId: scope.userId,
mateId: scope.mateId,
mateName: scope.mateName || null,
};
if (!extra) return base;
return Object.assign(base, extra);
}
// Identify a live record that this module wrote, by provenance rather than by
// parsing an import key. A record from another namespace, or from a source
// version this build does not understand, is left strictly alone: reconciling
// it could tombstone something whose meaning we do not know.
function isMateSourceEntry(entry) {
if (!entry || entry.deleted === true) return false;
if (entry.kind === "chunk") return false;
var source = entry.source;
if (!source || source.namespace !== SOURCE_NAMESPACE) return false;
if (typeof source.sourceVersion !== "number" || source.sourceVersion > SOURCE_VERSION) return false;
var name = source.fileName;
if (!name || typeof name !== "string") return false;
// The source name and relative path must agree, so neither field alone is trusted.
if (safeSourceName(name) !== name) return false;
return source.relPath === relativeSource(name);
}
function fileKey(name) {
return "file:" + name;
}
function journalKey(name, hash) {
return "journal:" + name + ":" + hash.substring(0, 24);
}
function newSummary() {
return {
created: 0,
revised: 0,
revived: 0,
unchanged: 0,
deleted: 0,
absent: 0,
"already-deleted": 0,
chunked: 0,
malformedLines: 0,
identitySkipped: 0,
excluded: 0,
unsupported: 0,
empty: 0,
failed: 0,
errors: [],
};
}
function record(summary, action) {
if (summary[action] === undefined) summary[action] = 0;
summary[action]++;
}
function readStat(filePath) {
try {
return fs.statSync(filePath);
} catch (e) {
return null;
}
}
function safeKnowledgeFileStat(root, name) {
var parts = name.split("/"); var cursor = root;
for (var i = 0; i < parts.length; i++) {
cursor = path.join(cursor, parts[i]); var stat;
try { stat = fs.lstatSync(cursor); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
if (stat.isSymbolicLink()) throw new Error("Knowledge source contains a symbolic link.");
if (i < parts.length - 1 && !stat.isDirectory()) throw new Error("Knowledge source path is not a directory.");
if (i === parts.length - 1 && !stat.isFile()) return null;
}
return fs.statSync(cursor);
}
// --- File-addressed sources -------------------------------------------
function syncFile(importer, scope, name, kind, filePath, stat, actor, summary) {
if (!stat) {
// The legacy file is gone. Tombstone rather than leaving stale content live.
var removed = importer.removeRecord(fileKey(name), {
actor: actor,
source: provenance(scope, name, { type: kind, reason: "source-removed" }),
});
record(summary, removed.action);
return;
}
if (stat.size > MAX_SOURCE_BYTES) {
summary.failed++;
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Source exceeds the maximum representable size." });
return;
}
var content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch (e) {
summary.failed++;
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Unreadable source: " + e.message });
return;
}
if (!content.trim()) {
// An emptied file is a logical removal, not a blank record.
var emptied = importer.removeRecord(fileKey(name), {
actor: actor,
source: provenance(scope, name, { type: kind, reason: "source-emptied" }),
});
summary.empty++;
if (emptied.action === "deleted") summary.deleted++;
return;
}
var result = importer.importRecord({
importKey: fileKey(name),
sourceHash: knowledgeImport.contentHash(content),
kind: kind,
name: name,
content: content,
at: Math.round(stat.mtimeMs),
actor: actor,
source: provenance(scope, name, {
type: kind,
sourceBytes: stat.size,
sourceModifiedAt: stat.mtimeMs,
}),
});
record(summary, result.action);
if (content.length > knowledgeImport.MAX_CONTENT_CHARS) summary.chunked++;
// Verified whenever something was actually written. An unchanged source was
// already verified when it landed, so the steady-state path stays cheap.
if (result.action !== "unchanged") verifyRoundTrip(importer, fileKey(name), scope, name, summary);
}
// --- Line-addressed journals ------------------------------------------
// Reconciles the whole journal: every present line is imported, and every line
// previously imported from this journal that is no longer present is
// tombstoned. A digest deleted through the memory UI therefore stops being a
// live memory instead of lingering in the new backend.
function syncJournal(importer, scope, name, kind, filePath, stat, actor, summary) {
var raw = "";
if (stat) {
if (stat.size > MAX_SOURCE_BYTES) {
summary.failed++;
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Source exceeds the maximum representable size." });
return;
}
try {
raw = fs.readFileSync(filePath, "utf8");
} catch (e) {
summary.failed++;
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Unreadable source: " + e.message });
return;
}
}
var present = {};
var lines = raw.split("\n");
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var parsed = null;
try {
parsed = JSON.parse(line);
} catch (e) {
summary.malformedLines++;
continue;
}
// A journal entry is a plain object. An array or a bare scalar parses
// cleanly but is not a record, so it is skipped rather than imported.
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
summary.malformedLines++;
continue;
}
var hash = knowledgeImport.contentHash(line);
var key = journalKey(name, hash);
present[key] = true;
var result = importer.importRecord({
importKey: key,
sourceHash: hash,
kind: kind,
name: name,
content: line,
actor: actor,
source: provenance(scope, name, {
type: kind,
lineIndex: i,
sourceDate: typeof parsed.date === "string" ? parsed.date : null,
sourceBytes: Buffer.byteLength(line, "utf8"),
sourceModifiedAt: stat ? stat.mtimeMs : null,
}),
});
record(summary, result.action);
if (line.length > knowledgeImport.MAX_CONTENT_CHARS) summary.chunked++;
if (result.action === "created" || result.action === "revived") {
verifyRoundTrip(importer, key, scope, name, summary);
}
}
// Every live line this module imported from this journal that the legacy
// file no longer contains. Selected by provenance, so a record from another
// namespace sharing a file name is never touched.
var stale = [];
var live = importer.entries();
for (var e = 0; e < live.length; e++) {
if (!isMateSourceEntry(live[e])) continue;
if (live[e].source.fileName !== name) continue;
if (present[live[e].importKey]) continue;
stale.push(live[e].importKey);
}
for (var s = 0; s < stale.length; s++) {
var removed = importer.removeRecord(stale[s], {
actor: actor,
source: provenance(scope, name, { type: kind, reason: "line-removed" }),
});
record(summary, removed.action);
}
}
// No source may be reported as successfully mirrored while its content cannot
// be read back exactly.
function verifyRoundTrip(importer, importKey, scope, name, summary) {
var entries = importer.entries();
var entry = null;
for (var i = 0; i < entries.length; i++) {
if (entries[i].importKey === importKey) { entry = entries[i]; break; }
}
if (!entry) {
summary.failed++;
summary.errors.push({ scopeId: scope.scopeId, file: name, message: "Imported record is not readable: " + importKey });
return;
}
var read = importer.readContent(entry);
if (!read.complete) {
summary.failed++;
summary.errors.push({
scopeId: scope.scopeId,
file: name,
message: (entry.chunked ? "Chunked source did not reassemble: " : "Stored source did not verify: ") + (read.reason || "unknown"),
});
}
}
// --- Public API --------------------------------------------------------
function importerFor(scope, baseDir) {
return knowledgeImport.createKnowledgeImporter({ scopeId: scope.scopeId, baseDir: baseDir });
}
// Reconcile exactly one Mate knowledge source against the new backend. Safe to
// call after any legacy write, including a delete. Never throws.
function syncMateSource(opts) {
var options = opts || {};
var summary = options.summary || newSummary();
try {
var scope = options.scope || scopeForMateDir(options.mateDir);
if (!scope) return summary;
if (options.mateName) scope.mateName = options.mateName;
var rawName = String(options.fileName || ""); var name = safeSourceName(rawName);
if (!name) { if (rawName) summary.unsupported++; return summary; }
if (IDENTITY_FILES.indexOf(name) !== -1) { summary.identitySkipped++; return summary; }
if (EXCLUDED_FILES.indexOf(name) !== -1) { summary.excluded++; return summary; }
var kind = sourceKindFor(name);
if (!kind) { summary.unsupported++; return summary; }
var actor = normalizeActor(options.actor);
var importer = options.importer || importerFor(scope, options.baseDir);
var knowledgeRoot = path.join(scope.mateDir, "knowledge"); var filePath = path.join(knowledgeRoot, name);
var stat = safeKnowledgeFileStat(knowledgeRoot, name);
if (isJournal(name)) syncJournal(importer, scope, name, kind, filePath, stat, actor, summary);
else syncFile(importer, scope, name, kind, filePath, stat, actor, summary);
} catch (e) {
summary.failed++;
summary.errors.push({ message: e.message });
}
return summary;
}
function reconcileSource(opts) {
return syncMateSource(opts);
}
// Reconcile every durable source in one Mate directory, in both directions.
function reconcileMate(opts) {
var options = opts || {};
var summary = options.summary || newSummary();
var scope = options.scope || scopeForMateDir(options.mateDir);
if (!scope) return summary;
if (options.mateName) scope.mateName = options.mateName;
var knowledgeDir = path.join(scope.mateDir, "knowledge");
var present = {};
var entries = [];
function discover(relative) {
var folder = relative ? path.join(knowledgeDir, relative) : knowledgeDir; var children;
try { children = fs.readdirSync(folder, { withFileTypes: true }); } catch (error) { return; }
for (var d = 0; d < children.length; d++) {
var name = relative ? relative + "/" + children[d].name : children[d].name; var stat;
try { stat = fs.lstatSync(path.join(knowledgeDir, name)); } catch (error) { continue; }
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) discover(name); else if (stat.isFile()) entries.push({ name: name });
}
}
discover("");
var importer = options.importer || importerFor(scope, options.baseDir);
function reconcileOne(fileName) {
syncMateSource({
scope: scope,
fileName: fileName,
actor: options.actor,
importer: importer,
baseDir: options.baseDir,
summary: summary,
});
}
for (var i = 0; i < entries.length; i++) {
// Recorded as present whatever its kind, so an excluded or unsupported file
// that still exists is never mistaken for a deleted source.
present[entries[i].name] = true;
reconcileOne(entries[i].name);
}
var absent = {};
var live = importer.entries();
for (var e = 0; e < live.length; e++) {
if (!isMateSourceEntry(live[e])) continue;
var name = live[e].source.fileName;
if (present[name] || absent[name]) continue;
absent[name] = true;
}
var absentNames = Object.keys(absent);
for (var a = 0; a < absentNames.length; a++) reconcileOne(absentNames[a]);
return summary;
}
// Fire-and-forget wrapper for legacy write paths. A mirroring failure must
// never break the legacy write that already succeeded.
function syncQuietly(opts) {
try {
var summary = syncMateSource(opts);
if (summary.failed > 0 && summary.errors.length > 0) {
console.error("[knowledge-sync] Mate knowledge mirror failed:", summary.errors[0].message);
}
return summary;
} catch (e) {
console.error("[knowledge-sync] Mate knowledge mirror failed:", e.message);
return null;
}
}
module.exports = {
SOURCE_NAMESPACE: SOURCE_NAMESPACE,
SOURCE_VERSION: SOURCE_VERSION,
MAX_SOURCE_BYTES: MAX_SOURCE_BYTES,
IDENTITY_FILES: IDENTITY_FILES,
EXCLUDED_FILES: EXCLUDED_FILES,
JOURNAL_KINDS: JOURNAL_KINDS,
ownerKeyForRoot: ownerKeyForRoot,
scopeIdFor: scopeIdFor,
scopeForMateDir: scopeForMateDir,
sourceKindFor: sourceKindFor,
isMateSourceEntry: isMateSourceEntry,
normalizeActor: normalizeActor,
newSummary: newSummary,
fileKey: fileKey,
journalKey: journalKey,
importerFor: importerFor,
syncMateSource: syncMateSource,
reconcileSource: reconcileSource,
reconcileMate: reconcileMate,
syncQuietly: syncQuietly,
};