Ctrl K
ComunidadGuías…

lib/knowledge-file-store.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var crypto = require("crypto");
var fs = require("fs");
var path = require("path");

var MAX_CONTENT_BYTES = 2 * 1024 * 1024;
var RESERVED = { "session-digests.jsonl": true, "sticky-notes.md": true, "memory-summary.md": true };

function revision(content) { return crypto.createHash("sha256").update(content, "utf8").digest("hex"); }
function cleanName(value) {
  if (typeof value !== "string") return null;
  var name = value.replace(/\\/g, "/").replace(/^\.\//, "");
  if (!name || name.length > 500 || name[0] === "/" || /[\x00-\x1f\x7f]/.test(name)) return null;
  var parts = name.split("/");
  if (parts.length > 20 || parts.some(function (part) { return !part || part === "." || part === ".." || part.length > 120; })) return null;
  if (!/\.(md|jsonl)$/i.test(name) || RESERVED[name]) return null;
  return parts.join("/");
}
function validName(value) { return cleanName(value) !== null; }
function itemType(name) { return /\.jsonl$/i.test(name) ? "db" : "text"; }
function atomicJson(target, value) {
  var temporary = target + ".tmp-" + process.pid + "-" + crypto.randomBytes(6).toString("hex");
  try { fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, target); }
  finally { try { fs.unlinkSync(temporary); } catch (error) {} }
}

function createKnowledgeFileStore(cwd, options) {
  var directory = path.join(cwd, "knowledge");
  var settings = options || {};
  var metaRoot = settings.metaDir || path.join(cwd, ".clay", "knowledge-documents");
  var registryPath = path.join(metaRoot, "documents.json");
  var historyRoot = path.join(metaRoot, "history");
  var fileCache = {};
  function safeDirectory(target, create) {
    var stat;
    try { stat = fs.lstatSync(target); }
    catch (error) { if (!create || error.code !== "ENOENT") throw error; fs.mkdirSync(target, { recursive: true, mode: 0o700 }); stat = fs.lstatSync(target); }
    if (!stat.isDirectory() || stat.isSymbolicLink()) throw Object.assign(new Error("Knowledge metadata path is not a safe directory."), { code: "UNSAFE_PATH" });
  }
  function ensureMetadata(createHistory) {
    safeDirectory(metaRoot, true);
    var registryStat;
    try { registryStat = fs.lstatSync(registryPath); } catch (error) { if (error.code !== "ENOENT") throw error; }
    if (registryStat && (!registryStat.isFile() || registryStat.isSymbolicLink())) throw Object.assign(new Error("Knowledge metadata registry is not a safe file."), { code: "UNSAFE_PATH" });
    if (createHistory) safeDirectory(historyRoot, true);
    else { try { safeDirectory(historyRoot, false); } catch (error) { if (error.code !== "ENOENT") throw error; } }
  }
  function ensureDirectory(create) {
    var stat;
    try { stat = fs.lstatSync(directory); }
    catch (error) { if (!create || error.code !== "ENOENT") throw error; fs.mkdirSync(directory, { recursive: true }); stat = fs.lstatSync(directory); }
    if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Knowledge directory is not a safe directory.");
  }
  function targetPath(name, createParents) {
    var clean = cleanName(name); if (!clean) throw new Error("Invalid knowledge file name."); ensureDirectory(!!createParents);
    var parts = clean.split("/"); var cursor = directory;
    for (var i = 0; i < parts.length - 1; i++) {
      cursor = path.join(cursor, parts[i]); var stat;
      try { stat = fs.lstatSync(cursor); }
      catch (error) { if (!createParents || error.code !== "ENOENT") throw error; fs.mkdirSync(cursor, { mode: 0o700 }); stat = fs.lstatSync(cursor); }
      if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Knowledge path contains an unsafe directory.");
    }
    return path.join(directory, clean);
  }
  function registry() {
    ensureMetadata(false);
    try { var parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")); return parsed && parsed.version === 1 && parsed.documents ? parsed : { version: 1, documents: {} }; }
    catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return { version: 1, documents: {} }; throw error; }
  }
  function writeRegistry(value) { ensureMetadata(false); atomicJson(registryPath, value); }
  function existing(name) {
    var clean = cleanName(name); if (!clean) throw new Error("Invalid knowledge file name."); var target; var stat;
    try { target = targetPath(clean, false); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
    try { stat = fs.lstatSync(target); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
    if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Knowledge file is not a safe regular file.");
    if (stat.size > MAX_CONTENT_BYTES) throw Object.assign(new Error("Knowledge document exceeds the safe read limit."), { code: "TOO_LARGE" });
    var cached = fileCache[clean]; var content;
    if (cached && cached.size === stat.size && cached.mtime === stat.mtimeMs && cached.ctime === stat.ctimeMs && cached.ino === stat.ino) content = cached.content;
    else { content = fs.readFileSync(target, "utf8"); fileCache[clean] = { content: content, revision: revision(content), size: stat.size, mtime: stat.mtimeMs, ctime: stat.ctimeMs, ino: stat.ino }; }
    cached = fileCache[clean]; return { name: clean, content: content, revision: cached.revision, size: stat.size, mtime: stat.mtimeMs, itemType: itemType(clean) };
  }
  function walk(relative, out) {
    var base = relative ? path.join(directory, relative) : directory; var names = fs.readdirSync(base);
    for (var i = 0; i < names.length; i++) {
      var rel = relative ? relative + "/" + names[i] : names[i]; var stat;
      try { stat = fs.lstatSync(path.join(directory, rel)); } catch (error) { continue; }
      if (stat.isSymbolicLink()) continue;
      if (stat.isDirectory()) { walk(rel, out); continue; }
      if (!stat.isFile() || !validName(rel)) continue;
      var item; try { item = existing(rel); } catch (error) {
        if (error.code === "TOO_LARGE") item = { name: rel, content: null, revision: null, size: stat.size, mtime: stat.mtimeMs, itemType: itemType(rel), readError: error.message };
        else continue;
      }
      if (item) out.push(item);
    }
  }
  function reconcileIdentity(items) {
    var data = registry(); var changed = false; var used = {}; var currentNames = {};
    items.forEach(function (item) { currentNames[item.name] = true; });
    Object.keys(data.documents).forEach(function (id) { if (!data.documents[id].deleted) used[data.documents[id].name] = id; });
    for (var i = 0; i < items.length; i++) {
      var id = used[items[i].name];
      if (!id) {
        var candidates = items[i].revision ? Object.keys(data.documents).filter(function (key) { var doc = data.documents[key]; return !currentNames[doc.name] && doc.lastRevision === items[i].revision; }) : [];
        id = candidates.length === 1 ? candidates[0] : crypto.randomUUID(); changed = true;
      }
      if (!data.documents[id] || data.documents[id].name !== items[i].name || data.documents[id].lastRevision !== items[i].revision || data.documents[id].deleted) changed = true;
      data.documents[id] = Object.assign({}, data.documents[id] || {}, { id: id, name: items[i].name,
        lastRevision: items[i].revision || data.documents[id] && data.documents[id].lastRevision || null, deleted: false });
      items[i].id = id; used[items[i].name] = id;
    }
    Object.keys(data.documents).forEach(function (id) {
      var found = items.some(function (item) { return item.id === id; });
      if (!found && !data.documents[id].deleted) { data.documents[id].deleted = true; changed = true; }
    });
    if (changed) writeRegistry(data); return data;
  }
  function list() {
    try { ensureDirectory(false); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
    var items = []; walk("", items); reconcileIdentity(items);
    return items.map(function (item) { return { id: item.id, name: item.name, path: item.name, folder: path.posix.dirname(item.name) === "." ? "" : path.posix.dirname(item.name),
      itemType: item.itemType, size: item.size, mtime: item.mtime, revision: item.revision, writable: item.itemType === "text" }; })
      .sort(function (a, b) { return a.name.localeCompare(b.name); });
  }
  function read(name) {
    ensureDirectory(false); var item = existing(name); if (!item) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
    var data = registry(); var id = Object.keys(data.documents).filter(function (key) { return !data.documents[key].deleted && data.documents[key].name === item.name; })[0];
    if (!id) { list(); data = registry(); id = Object.keys(data.documents).filter(function (key) { return !data.documents[key].deleted && data.documents[key].name === item.name; })[0]; }
    return Object.assign(item, { id: id || null });
  }
  function assertExpected(current, expected) {
    if (!current && expected) throw Object.assign(new Error("This document was removed since it was opened."), { code: "STALE", currentRevision: null });
    if (current && (!expected || expected !== current.revision)) throw Object.assign(new Error("This document changed since it was opened."), { code: "STALE", currentRevision: current.revision });
  }
  function snapshot(item, reason) {
    if (!item) return;
    var listed = list(); var identity = listed.filter(function (entry) { return entry.name === item.name; })[0]; if (!identity) return; ensureMetadata(true);
    var folder = path.join(historyRoot, identity.id); safeDirectory(folder, true);
    var target = path.join(folder, Date.now() + "-" + item.revision + ".md");
    try { fs.writeFileSync(target, item.content, { flag: "wx", mode: 0o600 }); }
    catch (error) {
      // A move and its link rewrite can snapshot the same revision in one millisecond.
      if (error.code !== "EEXIST") throw error;
      var stat = fs.lstatSync(target);
      if (!stat.isFile() || stat.isSymbolicLink() || fs.readFileSync(target, "utf8") !== item.content) throw error;
    }
  }
  function save(name, content, expectedRevision) {
    var clean = cleanName(name); if (!clean || itemType(clean) !== "text") throw new Error("Only valid Markdown documents can be edited.");
    if (typeof content !== "string") throw new Error("Knowledge content is required.");
    if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) throw new Error("Knowledge document is too large.");
    ensureDirectory(true); var current = existing(clean); assertExpected(current, expectedRevision); if (current) snapshot(current, "before-save");
    var target = targetPath(clean, true); var temporary = path.join(path.dirname(target), ".clay-knowledge-" + process.pid + "-" + crypto.randomBytes(8).toString("hex"));
    try { fs.writeFileSync(temporary, content, { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, target); }
    finally { try { fs.unlinkSync(temporary); } catch (error) {} }
    delete fileCache[clean];
    return read(clean);
  }
  function rename(from, to, expectedRevision) {
    var cleanFrom = cleanName(from); var cleanTo = cleanName(to);
    if (!cleanTo || itemType(cleanTo) !== "text") throw new Error("Only valid Markdown documents can be renamed or moved.");
    if (!cleanFrom || itemType(cleanFrom) !== "text") throw Object.assign(new Error("This Knowledge source is read only."), { code: "READ_ONLY" });
    ensureDirectory(false); var current = existing(cleanFrom); if (!current) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
    assertExpected(current, expectedRevision); if (existing(cleanTo)) throw Object.assign(new Error("A document with that path already exists."), { code: "EXISTS" }); snapshot(current, "before-move");
    fs.renameSync(targetPath(cleanFrom, false), targetPath(cleanTo, true));
    delete fileCache[cleanFrom];
    var data = registry(); Object.keys(data.documents).forEach(function (id) { if (data.documents[id].name === cleanFrom && !data.documents[id].deleted) data.documents[id].name = cleanTo; }); writeRegistry(data);
    return read(cleanTo);
  }
  function remove(name, expectedRevision) {
    var clean = cleanName(name); if (!clean || itemType(clean) !== "text") throw Object.assign(new Error("This Knowledge source is read only."), { code: "READ_ONLY" });
    ensureDirectory(false); var current = existing(clean); if (!current) throw Object.assign(new Error("Knowledge file not found."), { code: "ENOENT" });
    assertExpected(current, expectedRevision); snapshot(current, "before-delete"); fs.unlinkSync(targetPath(clean, false)); delete fileCache[clean]; list(); return current;
  }
  function history(id) {
    if (!/^[a-f0-9-]{20,50}$/i.test(String(id || ""))) throw new Error("Invalid document identity."); ensureMetadata(false); var folder = path.join(historyRoot, id); var names;
    try { safeDirectory(folder, false); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
    try { names = fs.readdirSync(folder); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
    return names.filter(function (name) { return /^\d+-[a-f0-9]{64}\.md$/.test(name); }).sort().reverse().slice(0, 50).map(function (name) {
      var target = path.join(folder, name); var stat = fs.lstatSync(target);
      if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CONTENT_BYTES) throw Object.assign(new Error("Knowledge history contains an unsafe version."), { code: "UNSAFE_PATH" });
      return { version: name, size: stat.size, createdAt: Number(name.split("-")[0]) };
    });
  }
  function readHistory(id, versionName) {
    var versions = history(id); if (!versions.some(function (entry) { return entry.version === versionName; })) throw Object.assign(new Error("Knowledge history version not found."), { code: "ENOENT" });
    return { version: versionName, content: fs.readFileSync(path.join(historyRoot, id, versionName), "utf8") };
  }
  function restore(id, versionName, name, expectedRevision) {
    if (!/^[a-f0-9-]{20,50}$/i.test(String(id || "")) || !/^\d+-[a-f0-9]{64}\.md$/.test(String(versionName || ""))) throw new Error("Invalid history version.");
    var selected = readHistory(id, versionName);
    var data = registry(); var record = data.documents[id]; if (!record) throw Object.assign(new Error("Knowledge document identity not found."), { code: "ENOENT" });
    var current = existing(name); var restored = save(name, selected.content, current ? expectedRevision || null : null);
    data = registry(); Object.keys(data.documents).forEach(function (key) { if (key !== id && data.documents[key].name === restored.name && !data.documents[key].deleted) delete data.documents[key]; });
    data.documents[id] = Object.assign({}, record, { id: id, name: restored.name, lastRevision: restored.revision, deleted: false }); writeRegistry(data);
    return Object.assign(restored, { id: id });
  }
  function deleted() {
    var data = registry(); return Object.keys(data.documents).filter(function (id) { return data.documents[id].deleted; }).map(function (id) {
      return { id: id, name: data.documents[id].name, revision: data.documents[id].lastRevision || null, deleted: true, historyCount: history(id).length };
    }).filter(function (item) { return item.historyCount > 0; }).sort(function (a, b) { return a.name.localeCompare(b.name); });
  }
  return { list: list, read: read, save: save, rename: rename, remove: remove, history: history, readHistory: readHistory, restore: restore, deleted: deleted };
}

module.exports = { createKnowledgeFileStore: createKnowledgeFileStore, revision: revision, validName: validName, cleanName: cleanName, itemType: itemType, MAX_CONTENT_BYTES: MAX_CONTENT_BYTES };