Ctrl K
ComunidadGuías…

lib/knowledge-import.js

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

Archivo original ↗Preguntar a KiBot
// Import-keyed projection over the Clay-wide knowledge record backend.
//
// A migration and a live write-through bridge both copy legacy content in
// repeatedly: on every restart, after a partial run, after every mutation, and
// potentially from two daemons at once. This layer makes that safe by
// addressing every imported record with a stable import key derived from the
// source, so re-importing the same source is a no-op rather than a duplicate.
//
// Change rule, applied by the caller through the key it chooses:
//   - File-addressed sources use a key derived from the file name. Re-importing
//     changed content revises the existing record, keeping the prior revision
//     and its provenance.
//   - Line-addressed sources (append-only journals) fold the line's content
//     hash into the key. An edited line is therefore a new, deterministic
//     import rather than a revision, which matches how those journals are
//     actually written.
//
// Content is never truncated. A source larger than one record is stored as
// content-addressed chunks and reassembled exactly, with the whole-source hash
// verified on read. A caller that cannot read a source back completely is told
// so rather than handed a silently partial string.

var crypto = require("crypto");
var recordStore = require("./knowledge-record-store");

var IMPORT_VERSION = 2;
// Worst-case JSON/UTF-8 expansion of an 8000 character slice stays well inside
// the record store's 64KB single-record limit.
var CHUNK_CHARS = 8000;
var MAX_CONTENT_CHARS = CHUNK_CHARS;
var MAX_NAME_CHARS = 200;
var MAX_CHUNKS = 8192;
var NUL = String.fromCharCode(0);
var CONTROL_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]+", "g");

function contentHash(value) {
  return crypto.createHash("sha256").update(String(value == null ? "" : value), "utf8").digest("hex").substring(0, 32);
}

function cleanLine(value, max) {
  if (typeof value !== "string") return "";
  var text = value.replace(CONTROL_PATTERN, " ").replace(/\s+/g, " ").trim();
  return text.length > max ? text.substring(0, max) : text;
}

// Content is stored verbatim. Every character survives a JSONL round trip,
// including NUL, which JSON.stringify escapes as a six-character sequence and JSON.parse restores
// exactly; it is not a newline, so it cannot split a record either. Removing
// any character here would break reassembly against the source hash.
function exactContent(value) {
  return typeof value === "string" ? value : "";
}

function splitChunks(content) {
  var chunks = [];
  for (var offset = 0; offset < content.length; offset += CHUNK_CHARS) {
    chunks.push(content.substring(offset, offset + CHUNK_CHARS));
  }
  return chunks;
}

function chunkKey(importKey, position) {
  return importKey + "#c" + position;
}

function createKnowledgeImporter(opts) {
  var options = opts || {};
  var scopeId = options.scopeId;
  var store = recordStore.createRecordStore({ scopeId: scopeId, baseDir: options.baseDir });

  function refFor(rootId) {
    var digest = crypto.createHash("sha256").update(scopeId + NUL + String(rootId)).digest("base64url");
    return "know:" + digest.substring(0, 24);
  }

  // Incrementally maintained so importing N sources stays O(N) rather than
  // re-folding the whole scope once per source. The bridge runs on every
  // mutation and the migration on every startup, so this must stay cheap.
  var indexed = null;

  function resetIndex() {
    indexed = { byKey: new Map(), byRoot: new Map(), duplicates: 0, consumed: 0 };
  }

  function foldRecord(record) {
    if (record.op === "create" && record.importKey) {
      // First create for an import key wins. A duplicate that slipped past the
      // run lock is folded away rather than double-counted, so the projection
      // stays correct even if the lock ever fails.
      if (indexed.byKey.has(record.importKey)) {
        indexed.duplicates++;
        return;
      }
      var state = {
        importKey: record.importKey,
        rootId: record.id,
        ref: refFor(record.id),
        sourceHash: record.sourceHash || null,
        contentHash: record.contentHash || record.sourceHash || null,
        kind: record.kind || null,
        name: record.name || null,
        chunked: record.chunked === true,
        chunkCount: record.chunkCount || 0,
        deleted: false,
        revisions: 1,
        importedAt: record.at || 0,
        updatedAt: record.at || 0,
      };
      indexed.byKey.set(record.importKey, state);
      indexed.byRoot.set(record.id, state);
      return;
    }
    if (!record.rootId) return;
    var target = indexed.byRoot.get(record.rootId);
    if (!target) return;
    if (record.op === "update") {
      if (record.sourceHash) target.sourceHash = record.sourceHash;
      if (record.contentHash) target.contentHash = record.contentHash;
      if (record.kind) target.kind = record.kind;
      if (record.name) target.name = record.name;
      target.chunked = record.chunked === true;
      target.chunkCount = record.chunkCount || 0;
      // An update always asserts that this content is current, so it revives a
      // record whose source came back after a tombstone.
      target.deleted = false;
    } else if (record.op === "delete") {
      target.deleted = true;
    } else {
      return;
    }
    target.revisions++;
    target.updatedAt = record.at || target.updatedAt;
  }

  function index() {
    var records = store.all();
    if (!indexed || records.length < indexed.consumed) resetIndex();
    for (var i = indexed.consumed; i < records.length; i++) foldRecord(records[i]);
    indexed.consumed = records.length;
    return { byKey: indexed.byKey, duplicates: indexed.duplicates };
  }

  // Folded content for every live import key. Used only for reassembling a
  // chunked source, which is rare, so it is computed on demand.
  function foldContents() {
    var records = store.all();
    var byRoot = new Map();
    var byKey = new Map();
    for (var i = 0; i < records.length; i++) {
      var record = records[i];
      if (record.op === "create" && record.importKey) {
        if (byKey.has(record.importKey)) continue;
        var state = { content: record.content || "", deleted: false };
        byKey.set(record.importKey, state);
        byRoot.set(record.id, state);
        continue;
      }
      if (!record.rootId) continue;
      var target = byRoot.get(record.rootId);
      if (!target) continue;
      if (record.op === "update") {
        if (typeof record.content === "string") target.content = record.content;
        target.deleted = false;
      } else if (record.op === "delete") {
        target.deleted = true;
      }
    }
    return byKey;
  }

  // Exact reassembly. Returns complete:false rather than a partial string when
  // a chunk is missing, tombstoned, or the whole-source hash does not verify.
  function readContent(entry) {
    if (!entry) return { complete: false, content: "", reason: "not-found" };
    if (!entry.chunked) {
      // Verified on the inline path too, so "complete" means the same thing
      // whether or not a source happened to need chunking.
      var inline = entry.content || "";
      if (entry.contentHash && contentHash(inline) !== entry.contentHash) {
        return { complete: false, content: "", reason: "hash-mismatch" };
      }
      return { complete: true, content: inline };
    }
    var contents = foldContents();
    var parts = [];
    for (var i = 0; i < entry.chunkCount; i++) {
      var part = contents.get(chunkKey(entry.importKey, i));
      if (!part || part.deleted) return { complete: false, content: "", reason: "missing-chunk", chunkIndex: i };
      parts.push(part.content);
    }
    var joined = parts.join("");
    if (entry.contentHash && contentHash(joined) !== entry.contentHash) {
      return { complete: false, content: "", reason: "hash-mismatch" };
    }
    return { complete: true, content: joined };
  }

  function append(payload) {
    return store.append(payload);
  }

  // Chunks are written before the parent record. A failure partway therefore
  // leaves orphan chunks and no parent, so the source is not reported as
  // imported and the next run retries it. Chunks are content-addressed, so the
  // retry re-uses whatever already landed.
  function writeChunks(importKey, chunks, entry, at) {
    var existingIndex = index().byKey;
    for (var i = 0; i < chunks.length; i++) {
      var key = chunkKey(importKey, i);
      var hash = contentHash(chunks[i]);
      var current = existingIndex.get(key);
      if (current && !current.deleted && current.sourceHash === hash) continue;
      var base = {
        op: current ? "update" : "create",
        scope: scopeId,
        importKey: key,
        sourceHash: hash,
        contentHash: hash,
        kind: "chunk",
        name: entry.name || "",
        chunkOf: importKey,
        chunkIndex: i,
        content: chunks[i],
        importVersion: IMPORT_VERSION,
        at: at,
      };
      if (current) {
        base.rootId = current.rootId;
      } else {
        var id = recordStore.newRecordId();
        base.id = id;
        base.rootId = id;
      }
      append(base);
    }
  }

  // Import one source unit. Returns what actually happened so a migration or a
  // bridge can report truthfully instead of assuming.
  function importRecord(input) {
    var entry = input || {};
    if (!entry.importKey || typeof entry.importKey !== "string") throw new Error("An import key is required.");
    var content = exactContent(entry.content);
    // Two distinct hashes, deliberately.
    //   sourceHash  - the caller's fingerprint of the legacy source, used for
    //                 change detection and for line-addressed import keys.
    //   storedHash  - always derived here from the exact bytes being written,
    //                 so reassembly verifies against what was actually stored
    //                 rather than against something the caller asserted.
    var storedHash = contentHash(content);
    var hash = entry.sourceHash || storedHash;
    var existing = index().byKey.get(entry.importKey) || null;

    if (existing && !existing.deleted && existing.sourceHash === hash) {
      return { action: "unchanged", ref: existing.ref, importKey: entry.importKey };
    }

    var at = Number.isFinite(entry.at) ? entry.at : Date.now();
    var chunks = content.length > MAX_CONTENT_CHARS ? splitChunks(content) : null;
    if (chunks) {
      if (chunks.length > MAX_CHUNKS) {
        throw new Error("Source exceeds the maximum representable size: " + entry.importKey);
      }
      writeChunks(entry.importKey, chunks, entry, at);
    }

    var payload = {
      op: existing ? "update" : "create",
      scope: scopeId,
      importKey: entry.importKey,
      sourceHash: hash,
      contentHash: storedHash,
      kind: entry.kind || "knowledge",
      name: cleanLine(entry.name || (existing && existing.name) || "", MAX_NAME_CHARS),
      chunked: !!chunks,
      chunkCount: chunks ? chunks.length : 0,
      content: chunks ? "" : content,
      source: entry.source || null,
      actor: entry.actor || null,
      importVersion: IMPORT_VERSION,
      at: at,
    };

    if (existing) {
      payload.rootId = existing.rootId;
      append(payload);
      return { action: existing.deleted ? "revived" : "revised", ref: existing.ref, importKey: entry.importKey };
    }
    var newId = recordStore.newRecordId();
    payload.id = newId;
    payload.rootId = newId;
    append(payload);
    return { action: "created", ref: refFor(newId), importKey: entry.importKey };
  }

  // Logical removal. The record and its history stay; the projection stops
  // returning it, so a memory deleted in the legacy store is not left active
  // in the new backend.
  function removeRecord(importKey, options2) {
    var settings = options2 || {};
    var existing = index().byKey.get(importKey) || null;
    if (!existing) return { action: "absent", importKey: importKey };
    if (existing.deleted) return { action: "already-deleted", ref: existing.ref, importKey: importKey };
    append({
      rootId: existing.rootId,
      op: "delete",
      scope: scopeId,
      importKey: importKey,
      kind: existing.kind,
      name: existing.name,
      source: settings.source || null,
      actor: settings.actor || null,
      importVersion: IMPORT_VERSION,
      at: Number.isFinite(settings.at) ? settings.at : Date.now(),
    });
    return { action: "deleted", ref: existing.ref, importKey: importKey };
  }

  // Read projection. Chunk records are internal and never surface here; use
  // readContent to reassemble a chunked entry.
  function entries(options2) {
    var settings = options2 || {};
    var records = store.all();
    var byRoot = new Map();
    var byKey = new Map();
    var order = [];
    var i;
    for (i = 0; i < records.length; i++) {
      var record = records[i];
      if (record.op !== "create" || !record.importKey) continue;
      if (byKey.has(record.importKey)) continue;
      var entry = {
        ref: refFor(record.id),
        importKey: record.importKey,
        kind: record.kind || null,
        name: record.name || null,
        content: record.content || "",
        chunked: record.chunked === true,
        chunkCount: record.chunkCount || 0,
        contentHash: record.contentHash || record.sourceHash || null,
        source: record.source || null,
        actor: record.actor || null,
        deleted: false,
        deletedAt: null,
        deletedBy: null,
        importedAt: record.at || 0,
        updatedAt: record.at || 0,
        revisions: 1,
      };
      byKey.set(record.importKey, entry);
      byRoot.set(record.id, entry);
      order.push(entry);
    }
    for (i = 0; i < records.length; i++) {
      var revision = records[i];
      if (revision.op === "create" || !revision.rootId) continue;
      var target = byRoot.get(revision.rootId);
      if (!target) continue;
      if (revision.op === "delete") {
        // Original authorship is preserved on `actor`; who removed it is
        // recorded separately so a tombstone is traceable too.
        target.deleted = true;
        target.deletedAt = revision.at || 0;
        target.deletedBy = revision.actor || null;
      } else if (revision.op === "update") {
        if (typeof revision.content === "string") target.content = revision.content;
        if (revision.name) target.name = revision.name;
        if (revision.kind) target.kind = revision.kind;
        if (revision.source) target.source = revision.source;
        if (revision.actor) target.actor = revision.actor;
        if (revision.contentHash) target.contentHash = revision.contentHash;
        target.chunked = revision.chunked === true;
        target.chunkCount = revision.chunkCount || 0;
        target.deleted = false;
        target.deletedAt = null;
        target.deletedBy = null;
      } else {
        continue;
      }
      target.updatedAt = revision.at || target.updatedAt;
      target.revisions++;
    }
    var out = [];
    for (i = 0; i < order.length; i++) {
      if (order[i].kind === "chunk" && settings.includeChunks !== true) continue;
      if (order[i].deleted && settings.includeDeleted !== true) continue;
      out.push(order[i]);
    }
    return out;
  }

  function stats() {
    var indexedNow = index();
    var base = store.stats();
    var live = 0;
    var deleted = 0;
    var chunks = 0;
    indexedNow.byKey.forEach(function (state) {
      if (state.kind === "chunk") { chunks++; return; }
      if (state.deleted) deleted++;
      else live++;
    });
    return {
      scopeId: scopeId,
      filePath: base.filePath,
      records: base.records,
      skippedRecords: base.skipped,
      imported: live,
      deleted: deleted,
      chunks: chunks,
      duplicates: indexedNow.duplicates,
    };
  }

  return {
    scopeId: scopeId,
    filePath: store.filePath,
    index: index,
    importRecord: importRecord,
    removeRecord: removeRecord,
    readContent: readContent,
    entries: entries,
    stats: stats,
  };
}

module.exports = {
  IMPORT_VERSION: IMPORT_VERSION,
  CHUNK_CHARS: CHUNK_CHARS,
  MAX_CONTENT_CHARS: MAX_CONTENT_CHARS,
  MAX_CHUNKS: MAX_CHUNKS,
  contentHash: contentHash,
  chunkKey: chunkKey,
  splitChunks: splitChunks,
  createKnowledgeImporter: createKnowledgeImporter,
};