Ctrl K
ComunidadGuías…

lib/mate-knowledge-service.js

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

Archivo original ↗Preguntar a KiBot
// Read projection and authorization for Mate Knowledge.
//
// Reads the continuously synchronized Clay Knowledge records, never the legacy
// files. Chunk records and tombstones are excluded, chunked content is
// reassembled and hash-verified, and a record that cannot be read back exactly
// is refused rather than returned partially.
//
// Authorization mirrors project-logs-service.js: a caller never states who it
// is. An ordinary Mate is bound to its own scope and can express no other, and
// only the authoritative builtin Clay host agent receives same-user cross-Mate
// reads. No binding ever spans users.

var path = require("path");
var builtinMates = require("./builtin-mates");
var knowledgeImport = require("./knowledge-import");
var knowledgeSearch = require("./knowledge-search");
var mateSync = require("./mate-knowledge-sync");
var textToolsModule = require("./mate-knowledge-text-tools");
var mateBoundModule = require("./mate-knowledge-mate-bound");

var MAX_PAGE = 50;
var DEFAULT_PAGE = 20;
var MAX_QUERY_CHARS = 200;
var MAX_PREVIEW_CHARS = 240;
var MAX_SNIPPET_CHARS = 320;
// Reads are bounded and continuable rather than refused. A record larger than
// one response is returned as verified slices the caller can page through.
var DEFAULT_READ_CHARS = 8000;
var MAX_READ_CHARS = 40000;
var REF_PATTERN = /^know:[A-Za-z0-9_-]{24}$/;

// Name outranks body slightly; a Mate's knowledge file names are meaningful,
// while journal lines all share one file name and rank on content.
var NAME_WEIGHT = 2;
var BODY_WEIGHT = 1;

function parseLimit(value) {
  var limit = Number(value);
  if (!Number.isFinite(limit)) limit = DEFAULT_PAGE;
  return Math.max(1, Math.min(MAX_PAGE, Math.floor(limit)));
}

function encodeCursor(offset) {
  return offset > 0 ? Buffer.from(String(offset), "utf8").toString("base64url") : null;
}

function decodeCursor(value) {
  if (!value) return 0;
  try {
    var parsed = Number(Buffer.from(String(value), "base64url").toString("utf8"));
    return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
  } catch (e) {
    return 0;
  }
}

function page(items, args) {
  var offset = decodeCursor(args && args.cursor);
  var limit = parseLimit(args && args.limit);
  return {
    items: items.slice(offset, offset + limit),
    nextCursor: offset + limit < items.length ? encodeCursor(offset + limit) : null,
    total: items.length,
  };
}

function parseOffset(value) {
  var offset = Number(value);
  if (!Number.isFinite(offset) || offset < 0) return 0;
  return Math.floor(offset);
}

function parseReadSize(value) {
  var size = Number(value);
  if (!Number.isFinite(size)) size = DEFAULT_READ_CHARS;
  return Math.max(1, Math.min(MAX_READ_CHARS, Math.floor(size)));
}

// Never end a slice inside a UTF-16 surrogate pair, so every slice is on its
// own a valid string and concatenating slices reproduces the source exactly.
function safeSliceEnd(text, end) {
  if (end >= text.length) return text.length;
  var code = text.charCodeAt(end - 1);
  if (code >= 0xd800 && code <= 0xdbff) return end - 1;
  return end;
}

function clean(value, max) {
  if (typeof value !== "string") return "";
  var text = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
  return text.length > max ? text.substring(0, max) : text;
}

function attachMateKnowledgeService(ctx) {
  var context = ctx || {};
  var getProjects = context.getProjects;
  var isMultiUser = context.isMultiUser;
  var resolveMate = context.resolveMate;
  var listMates = context.listMates || function () { return []; };
  var baseDir = context.baseDir;
  var importerCache = new Map();
  var textTools = textToolsModule.createMateKnowledgeTextTools({ baseDir: baseDir });

  function importerForScope(scopeId) {
    if (context.openImporter) return context.openImporter(scopeId);
    if (!importerCache.has(scopeId)) {
      importerCache.set(scopeId, knowledgeImport.createKnowledgeImporter({ scopeId: scopeId, baseDir: baseDir }));
    }
    return importerCache.get(scopeId);
  }

  // --- Read projection --------------------------------------------------

  // Bring this exact Mate's records up to date with its legacy files before
  // reading them, so a direct filesystem edit or an offline deletion is visible
  // on the next tool call instead of only after a daemon restart. Only ever
  // called for a scope the principal is already authorized for. Reconciliation
  // is idempotent, so a no-op call appends nothing.
  // A reconciliation failure means the record for that source may no longer
  // match the legacy file. Presenting the previous record would be stale
  // content dressed as current, so every failure is attributed to a source and
  // that source is withheld. A failure that cannot be attributed withholds the
  // whole scope, because there is no way to know which record is stale.
  function refresh(mate) {
    if (context.syncBeforeRead === false || !mate.mateDir) {
      return { failed: 0, files: {}, scopeFailed: false };
    }
    var summary;
    try {
      summary = mateSync.reconcileMate({
        mateDir: mate.mateDir,
        mateName: mate.mateName,
        baseDir: baseDir,
        importer: importerForScope(mate.scopeId),
        actor: { type: "system" },
      });
    } catch (e) {
      return { failed: 1, files: {}, scopeFailed: true };
    }
    var failed = (summary && summary.failed) || 0;
    if (failed === 0) return { failed: 0, files: {}, scopeFailed: false };

    var files = {};
    var attributed = 0;
    var errors = (summary && summary.errors) || [];
    for (var i = 0; i < errors.length; i++) {
      var name = errors[i] && errors[i].file;
      // Only a plain basename is trusted as an identity; anything else is
      // treated as unattributable rather than matched loosely.
      if (typeof name !== "string" || !name || path.basename(name) !== name) continue;
      if (!files[name]) attributed++;
      files[name] = true;
    }
    return { failed: failed, files: files, scopeFailed: attributed < failed };
  }

  // entries() already excludes chunk records and tombstones. Chunked sources
  // are reassembled and hash-verified here; one that does not verify is
  // reported as degraded and left out of ranking rather than half-returned.
  function materialize(mate, refreshed) {
    var state = refreshed || refresh(mate);
    var degraded = state.failed;
    // An unattributable failure withholds every record in this scope.
    if (state.scopeFailed) return { items: [], degraded: degraded, scopeFailed: true };
    var importer = importerForScope(mate.scopeId);
    var live = importer.entries();
    var out = [];
    for (var i = 0; i < live.length; i++) {
      var entry = live[i];
      var sourceName = entry.source && entry.source.fileName;
      // Withhold the previous record for a source that just failed to reconcile.
      if (sourceName && state.files[sourceName]) continue;
      var read = importer.readContent(entry);
      if (!read.complete) {
        degraded++;
        continue;
      }
      out.push({
        ref: entry.ref,
        kind: entry.kind,
        name: entry.name,
        content: read.content,
        updatedAt: entry.updatedAt,
        revisions: entry.revisions,
        relPath: entry.source ? entry.source.relPath : null,
        sourceDate: entry.source ? entry.source.sourceDate || null : null,
        mateId: mate.mateId,
        mateName: mate.mateName || null,
      });
    }
    return { items: out, degraded: degraded, scopeFailed: false };
  }

  // Public shapes never carry the full body; read_knowledge does that.
  function summary(item, includeMate) {
    var out = {
      ref: item.ref,
      kind: item.kind,
      name: item.name,
      updatedAt: item.updatedAt,
      revisions: item.revisions,
      relPath: item.relPath,
      sourceDate: item.sourceDate,
      preview: clean(item.content, MAX_PREVIEW_CHARS),
    };
    if (includeMate) {
      out.mateId = item.mateId;
      out.mateName = item.mateName;
    }
    return out;
  }

  function projectItem(item) {
    return {
      id: item.ref,
      fields: [
        { text: item.name || "", weight: NAME_WEIGHT },
        { text: item.content || "", weight: BODY_WEIGHT },
      ],
      meta: item,
    };
  }

  function collect(mates) {
    var items = [];
    var degraded = 0;
    var degradedScopes = 0;
    for (var i = 0; i < mates.length; i++) {
      var result = materialize(mates[i]);
      degraded += result.degraded;
      if (result.scopeFailed) degradedScopes++;
      for (var j = 0; j < result.items.length; j++) items.push(result.items[j]);
    }
    return { items: items, degraded: degraded, degradedScopes: degradedScopes };
  }

  // How much of the candidate set could actually be indexed. Reported so an
  // incomplete search is stated rather than implied by an empty result.
  function coverageOf(candidates) {
    var incomplete = 0;
    for (var i = 0; i < candidates.length; i++) {
      if (!knowledgeSearch.coverage(projectItem(candidates[i]).fields).complete) incomplete++;
    }
    return incomplete;
  }

  function listFor(mates, args, includeMate) {
    var options = args || {};
    var collected = collect(mates);
    var items = collected.items;
    if (options.kind) {
      var kind = clean(String(options.kind), 60);
      items = items.filter(function (item) { return item.kind === kind; });
    }
    items.sort(function (a, b) {
      return b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref);
    });
    var result = page(items, options);
    return {
      entries: result.items.map(function (item) { return summary(item, includeMate); }),
      nextCursor: result.nextCursor,
      total: result.total,
      degraded: collected.degraded,
      degradedScopes: collected.degradedScopes,
    };
  }

  function searchFor(mates, args, includeMate) {
    var options = args || {};
    var query = clean(options.query || "", MAX_QUERY_CHARS);
    if (!query) throw new Error("A search query is required.");
    var collected = collect(mates);
    var candidates = collected.items;
    if (options.kind) {
      var kind = clean(String(options.kind), 60);
      candidates = candidates.filter(function (item) { return item.kind === kind; });
    }
    var ranked = knowledgeSearch.rank(candidates, query, projectItem, candidates.length);
    var hits = [];
    for (var i = 0; i < ranked.length; i++) {
      var item = ranked[i].meta;
      var hit = summary(item, includeMate);
      hit.score = ranked[i].score;
      hit.snippet = clean(knowledgeSearch.snippet(item.content, query, MAX_SNIPPET_CHARS), MAX_SNIPPET_CHARS);
      hits.push(hit);
    }
    hits.sort(function (a, b) {
      return b.score - a.score || b.updatedAt - a.updatedAt || knowledgeSearch.compareIds(a.ref, b.ref);
    });
    var result = page(hits, options);
    return {
      results: result.items,
      nextCursor: result.nextCursor,
      total: result.total,
      degraded: collected.degraded,
      degradedScopes: collected.degradedScopes,
      // Records whose searchable text exceeds what the index can cover. A term
      // appearing only past that bound cannot be ranked, so say so.
      incompleteCoverage: coverageOf(candidates),
      coveredCharsPerRecord: knowledgeSearch.MAX_INDEXED_CHARS,
    };
  }

  // A ref is resolved only inside the scopes this principal is authorized for,
  // so an opaque ref from elsewhere simply does not exist here.
  //
  // The whole source is always reassembled and hash-verified before any slicing,
  // so a bounded read is a window onto verified content, never a shortcut past
  // verification. A record larger than one response is continued through
  // nextOffset rather than refused.
  function readFor(mates, args, includeMate) {
    var options = args || {};
    var ref = options.ref;
    if (typeof ref !== "string" || !REF_PATTERN.test(ref)) throw new Error("A valid knowledge reference is required.");
    var offset = parseOffset(options.offset);
    var maxChars = parseReadSize(options.maxChars);
    for (var i = 0; i < mates.length; i++) {
      var state = refresh(mates[i]);
      var importer = importerForScope(mates[i].scopeId);
      var live = importer.entries();
      for (var j = 0; j < live.length; j++) {
        if (live[j].ref !== ref) continue;
        // A record whose source just failed to reconcile is refused rather than
        // served from the previous, possibly stale, content.
        if (state.scopeFailed) {
          throw new Error("This Mate's Knowledge could not be brought up to date, so reads are withheld.");
        }
        var failedName = live[j].source && live[j].source.fileName;
        if (failedName && state.files[failedName]) {
          throw new Error("The source of this knowledge record could not be brought up to date: " + failedName);
        }
        var read = importer.readContent(live[j]);
        if (!read.complete) {
          throw new Error("This knowledge record could not be read back completely: " + (read.reason || "unknown"));
        }
        var total = read.content.length;
        if (offset > total) throw new Error("Offset " + offset + " is beyond the end of this record (" + total + " characters).");
        var end = safeSliceEnd(read.content, Math.min(total, offset + maxChars));
        var slice = read.content.substring(offset, end);
        var out = summary({
          ref: live[j].ref,
          kind: live[j].kind,
          name: live[j].name,
          content: read.content,
          updatedAt: live[j].updatedAt,
          revisions: live[j].revisions,
          relPath: live[j].source ? live[j].source.relPath : null,
          sourceDate: live[j].source ? live[j].source.sourceDate || null : null,
          mateId: mates[i].mateId,
          mateName: mates[i].mateName || null,
        }, includeMate);
        delete out.preview;
        out.content = slice;
        out.offset = offset;
        out.totalChars = total;
        out.nextOffset = end < total ? end : null;
        // True only when this single response carried the whole record.
        out.complete = offset === 0 && end >= total;
        return out;
      }
    }
    throw new Error("Knowledge record not found.");
  }

  // --- Binding ----------------------------------------------------------

  function scopeForMate(mateDir, mateId, mateName) {
    var scope = mateSync.scopeForMateDir(mateDir);
    if (!scope || scope.mateId !== mateId) return null;
    return { scopeId: scope.scopeId, mateId: mateId, mateName: mateName || null, mateDir: scope.mateDir };
  }

  // Exact live Mate project and session identity, confirmed against the server
  // Mate registry. Anything that fails to line up yields no capability.
  function resolve(source) {
    if (!source || source.isMate !== true || !source.mateId) return null;
    var project = getProjects().get(source.projectSlug);
    if (!project) return null;
    var status = project.getStatus();
    if (!status || status.isMate !== true) return null;
    if (status.mateId !== source.mateId || status.projectOwnerId !== source.projectOwnerId) return null;
    if (source.session) {
      var manager = project.getSessionManager ? project.getSessionManager() : project.sm;
      if (!manager || !manager.sessions || manager.sessions.get(source.session.localId) !== source.session) return null;
      if (source.projectOwnerId && source.session.ownerId && source.session.ownerId !== source.projectOwnerId) return null;
      if (isMultiUser() && (!source.projectOwnerId || source.session.ownerId !== source.projectOwnerId)) return null;
    }
    if (typeof resolveMate !== "function") return null;
    var mate = resolveMate(source.projectOwnerId || null, source.mateId);
    if (!mate || mate.lifecycleDeletion || mate.id !== source.mateId) return null;
    if (isMultiUser()) {
      if (!source.projectOwnerId) return null;
      if (mate.createdBy !== source.projectOwnerId) return null;
    }
    if (!status.path) return null;
    var own = scopeForMate(status.path, source.mateId, mate.name || null);
    if (!own) return null;

    var def = mate.builtinKey ? builtinMates.getBuiltinByKey(mate.builtinKey) : null;
    var isClay = !!(mate.builtinKey === "clay" && def && def.hostAgent === true);
    return { project: project, own: own, isClay: isClay, ownerId: source.projectOwnerId || null,
      databaseService: typeof project.getKnowledgeDbService === "function" ? project.getKnowledgeDbService() : null,
      actor: { type: "user", userId: source.session && source.session.ownerId || source.projectOwnerId || null, displayName: null } };
  }

  // A tool handler captured earlier stays valid only while its exact source
  // still resolves. Identity is re-derived on every call, so a session that has
  // since been dropped, a Mate that has been deleted, or a registry entry that
  // has changed hands loses the capability immediately rather than continuing
  // to work through a handler that was created while it was still live.
  function bind(source) {
    var initial = resolve(source);
    var generation = Number(source && source.session && source.session._sdkQueryGeneration || 0);
    if (!initial) return null;

    function revalidate() {
      var current = resolve(source);
      if (initial.isClay) require("./mate-knowledge-canonical-access").assertCanonicalKnowledgeQuery(source, getProjects().get(source.projectSlug), isMultiUser(), generation, context.findUser);
      if (!current) throw new Error("This Knowledge binding is no longer valid for the current session.");
      if (current.project !== initial.project || current.isClay !== initial.isClay || current.own.scopeId !== initial.own.scopeId || current.databaseService !== initial.databaseService) {
        throw new Error("This Knowledge binding is no longer valid for the current session.");
      }
      return current;
    }

    return initial.isClay ? createClayBound(revalidate, initial) : createMateBound(revalidate, initial);
  }

  // An ordinary Mate's own scope, and nothing else. There is no argument on
  // any tool that could widen it.
  function createMateBound(revalidate, initial) {
    return mateBoundModule.createMateBound({
      revalidate: revalidate, initial: initial, textTools: textTools,
      listFor: listFor, searchFor: searchFor, readFor: readFor,
    });
  }

  // Every Mate belonging to the same user, resolved fresh on each call so a
  // newly created or deleted Mate is reflected without a rebind. Never spans
  // users: the Mate list is derived from the bound owner id alone.
  function createClayBound(revalidate, initial) {
    function authorizedMates(mateId) {
      var current = revalidate();
      var ownerId = current.ownerId;
      var own = current.own;
      var out = [];
      var listed = listMates(ownerId) || [];
      for (var i = 0; i < listed.length; i++) {
        var entry = listed[i];
        if (!entry || !entry.id || !entry.dir) continue;
        var registered = resolveMate(ownerId, entry.id);
        if (!registered || registered.lifecycleDeletion || isMultiUser() && registered.createdBy !== ownerId) continue;
        var scope = scopeForMate(entry.dir, entry.id, entry.name || null);
        if (!scope) continue;
        out.push(scope);
      }
      if (out.length === 0) out.push(own);
      if (!mateId) return out;
      var wanted = String(mateId);
      var filtered = out.filter(function (scope) { return scope.mateId === wanted; });
      if (filtered.length === 0) throw new Error("Mate not found for the current user.");
      return filtered;
    }
    return {
      isClay: true,
      mateId: initial.own.mateId,
      scopeId: initial.own.scopeId,
      listMateKnowledge: function (args) {
        return listFor(authorizedMates(args && args.mateId), args || {}, true);
      },
      searchMateKnowledge: function (args) {
        return searchFor(authorizedMates(args && args.mateId), args || {}, true);
      },
      readMateKnowledge: function (args) {
        return readFor(authorizedMates(null), args || {}, true);
      },
    };
  }

  return { bind: bind };
}

module.exports = {
  MAX_PAGE: MAX_PAGE,
  MAX_READ_CHARS: MAX_READ_CHARS,
  DEFAULT_READ_CHARS: DEFAULT_READ_CHARS,
  REF_PATTERN: REF_PATTERN,
  NAME_WEIGHT: NAME_WEIGHT,
  BODY_WEIGHT: BODY_WEIGHT,
  attachMateKnowledgeService: attachMateKnowledgeService,
};