Ctrl K
ComunidadGuías…

lib/notes.js

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

Archivo original ↗Preguntar a KiBot
var fs = require("fs");
var path = require("path");
var crypto = require("crypto");
var config = require("./config");
var utils = require("./utils");
var lifecycle = require("./notes-lifecycle");
var checklist = require("./notes-checklist");

function createNotesManager(opts) {
  var cwd = opts.cwd;

  // Storage path: ~/.clay/notes/{encodedCwd}.json
  var notesDir = path.join(config.CONFIG_DIR, "notes");
  var encodedCwd = utils.resolveEncodedFile(notesDir, cwd, ".json");
  var notesFile = path.join(notesDir, encodedCwd + ".json");

  // In-memory cache
  var notes = loadFromDisk();

  function generateId() {
    return "n_" + Date.now() + "_" + crypto.randomBytes(3).toString("hex");
  }

  // Legacy files are normalized on load, never on read. Nothing is rewritten
  // just because a note was looked at; the projected fields reach disk the next
  // time some other write happens.
  function loadFromDisk() {
    try {
      var data = fs.readFileSync(notesFile, "utf8");
      var parsed = JSON.parse(data);
      return lifecycle.normalizeAll(parsed.notes || []).notes;
    } catch (e) {
      return [];
    }
  }

  function saveToDisk() {
    try {
      fs.mkdirSync(notesDir, { recursive: true });
      var tmpPath = notesFile + ".tmp";
      fs.writeFileSync(tmpPath, JSON.stringify({ notes: notes }, null, 2));
      fs.renameSync(tmpPath, notesFile);
      return true;
    } catch (e) {
      console.error("[notes] Failed to save:", e.message);
      return false;
    }
  }

  function list() {
    return notes;
  }

  function create(data) {
    var now = Date.now();
    var note = {
      id: generateId(),
      text: data.text || "",
      x: typeof data.x === "number" ? data.x : 100,
      y: typeof data.y === "number" ? data.y : 100,
      w: typeof data.w === "number" ? data.w : 240,
      h: typeof data.h === "number" ? data.h : 180,
      color: data.color || "purple",
      opacity: typeof data.opacity === "number" ? data.opacity : 0.64,
      checklistMode: data.checklistMode === true,
      checklistState: checklist.validState(data.checklistState) ? checklist.normalizeState(data.checklistState, data.text || "") : [],
      minimized: false,
      // A new note is always an open attention item.
      state: lifecycle.STATE_OPEN,
      closedAt: null,
      closedBy: null,
      zIndex: notes.length + 1,
      createdAt: now,
      updatedAt: now,
    };
    if (data.origin && data.origin.sessionId !== undefined) {
      note.origin = {
        sessionId: data.origin.sessionId,
        vendor: data.origin.vendor || "claude",
      };
    }
    notes.push(note);
    saveToDisk();
    return note;
  }

  function update(id, changes) {
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].id === id) {
        if (changes.checklistConvert === true) {
          if (changes.checklistSource !== notes[i].text || changes.checklistVersion !== notes[i].updatedAt || notes[i].checklistMode === true) return null;
          changes = Object.assign({}, changes, { text: "", checklistState: {}, checklistMode: true });
        }
        if (changes.checklistEdit === true && (notes[i].checklistMode !== true || changes.checklistSource !== notes[i].text || changes.checklistVersion !== notes[i].updatedAt || typeof changes.text !== "string")) return null;
        if (changes.checklistMode !== undefined && typeof changes.checklistMode !== "boolean") return null;
        if (changes.checklistState !== undefined && !checklist.validState(changes.checklistState)) return null;
        var checklistKey = checklist.validateChange(notes[i], changes);
        if (changes.checklistIndex !== undefined && !checklistKey) return null;
        var previous = {};
        var snapshotKeys = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity", "checklistMode", "checklistState", "hidden", "state", "closedAt", "closedBy", "updatedAt"];
        for (var s = 0; s < snapshotKeys.length; s++) previous[snapshotKeys[s]] = notes[i][snapshotKeys[s]];
        var allowed = ["text", "x", "y", "w", "h", "color", "minimized", "zIndex", "opacity", "checklistMode", "checklistState"];
        for (var j = 0; j < allowed.length; j++) {
          var key = allowed[j];
          if (changes[key] !== undefined) {
            notes[i][key] = changes[key];
          }
        }
        if (checklistKey) {
          var state = checklist.normalizeState(notes[i].checklistState, notes[i].text);
          state[checklistKey] = changes.checklistChecked === true;
          notes[i].checklistState = state;
        }
        if (changes.text !== undefined || changes.checklistState !== undefined || checklistKey) {
          notes[i].checklistState = checklist.normalizeState(notes[i].checklistState, notes[i].text);
        }
        // `hidden` is the legacy spelling of the lifecycle. Route it through the
        // transitions so state, hidden, and the close provenance can never
        // disagree, and so an old client cannot half-apply a close.
        if (changes.hidden !== undefined) {
          if (changes.hidden === true) lifecycle.applyClose(notes[i], changes.actor || null, Date.now());
          else lifecycle.applyReopen(notes[i]);
        }
        notes[i].updatedAt = Math.max(Date.now(), (previous.updatedAt || 0) + 1);
        if (!saveToDisk()) {
          for (var p = 0; p < snapshotKeys.length; p++) notes[i][snapshotKeys[p]] = previous[snapshotKeys[p]];
          return null;
        }
        return notes[i];
      }
    }
    return null;
  }

  // Close a note. This is what completion means: the record stays and only its
  // place on the active canvas changes. Idempotent.
  function close(id, actor) {
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].id !== id) continue;
      if (lifecycle.applyClose(notes[i], actor || null, Date.now())) {
        notes[i].updatedAt = Date.now();
        saveToDisk();
      }
      return notes[i];
    }
    return null;
  }

  // Human close includes the visible draft and an exact canonical snapshot.
  // Recheck it here, then persist draft + lifecycle/removal in one transaction.
  function closeHuman(id, changes, actor) {
    for (var i = 0; i < notes.length; i++) {
      var note = notes[i];
      if (note.id !== id) continue;
      if (note.text !== changes.expectedText || note.updatedAt !== changes.expectedVersion || typeof changes.draftText !== "string") return null;
      if (!changes.draftText.trim()) {
        notes.splice(i, 1);
        if (saveToDisk()) return { removed: note };
        notes.splice(i, 0, note);
        return null;
      }
      var updated = update(id, { text: changes.draftText, hidden: true, actor: actor });
      return updated ? { note: updated } : null;
    }
    return null;
  }

  // Reopen a closed note. Idempotent.
  function reopen(id) {
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].id !== id) continue;
      if (lifecycle.applyReopen(notes[i])) {
        notes[i].updatedAt = Date.now();
        saveToDisk();
      }
      return notes[i];
    }
    return null;
  }

  function openList() {
    return lifecycle.openNotes(notes);
  }

  function closedList() {
    return lifecycle.closedNotes(notes);
  }

  // Permanent deletion for the human-only closed-note path. Callers must still
  // authorize the actor and recheck the lifecycle before invoking this method.
  // The closed-state guard makes a stale confirmation harmless at storage.
  function removeClosed(id) {
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].id !== id || !lifecycle.isClosed(notes[i])) continue;
      var removed = notes[i];
      notes.splice(i, 1);
      if (saveToDisk()) return removed;
      notes.splice(i, 0, removed);
      return null;
    }
    return null;
  }

  // Retained for maintenance callers that must truly discard a record. It is
  // intentionally not exposed through WebSocket, MCP, or the UI.
  function remove(id) {
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].id === id) {
        notes.splice(i, 1);
        saveToDisk();
        return true;
      }
    }
    return false;
  }

  function bringToFront(id) {
    var maxZ = 0;
    for (var i = 0; i < notes.length; i++) {
      if (notes[i].zIndex > maxZ) maxZ = notes[i].zIndex;
    }
    // Normalize if z-index grows too large
    if (maxZ > 10000) {
      notes.sort(function (a, b) { return a.zIndex - b.zIndex; });
      for (var k = 0; k < notes.length; k++) {
        notes[k].zIndex = k + 1;
      }
      maxZ = notes.length;
    }
    return update(id, { zIndex: maxZ + 1 });
  }

  /**
   * Return formatted text of all active (non-hidden) notes.
   * Used to inject into mate CLAUDE.md so the mate can read them.
   */
  function getActiveNotesText() {
    var active = [];
    for (var i = 0; i < notes.length; i++) {
      if (lifecycle.isOpen(notes[i]) && notes[i].text) active.push(notes[i]);
    }
    if (active.length === 0) return "";
    var lines = [];
    for (var j = 0; j < active.length; j++) {
      var n = active[j];
      var label = n.color ? "[" + n.color + "]" : "";
      // Checklist notes project their saved completion, not stale Markdown marks.
      lines.push("- " + label + " " + checklist.effectiveText(n).trim());
    }
    return lines.join("\n");
  }

  return {
    list: list,
    openList: openList,
    closedList: closedList,
    create: create,
    update: update,
    close: close,
    closeHuman: closeHuman,
    reopen: reopen,
    remove: remove,
    removeClosed: removeClosed,
    bringToFront: bringToFront,
    getActiveNotesText: getActiveNotesText,
  };
}

module.exports = { createNotesManager: createNotesManager };