lib/notes.js
Rama main · réplica de lectura del repositorio original.
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 };