"""
π§ Session Memory β Reader + Writer + Feedback Editor nodes for ComfyUI.
Architecture (no graph cycle):
π§ Reader ββsession_memoryβββΊ system prompt βββΊ LLM βββΊ Summarizer
β β
β πΎ Writer
β β
β β Feedback Editor
β β
ββββββββββββββββββ sessions/{session_id}.json ββββββββββββ
Writer saves the run, then Feedback Editor shows it for rating/notes.
Reader loads everything (entries + feedback) at the start of the next Run.
"""
import json
import logging
import os
import re
import shutil
import threading
import time
from aiohttp import web
from server import PromptServer
logger = logging.getLogger(__name__)
# ββ Session file helpers βββββββββββββββββββββββββββββββββββββββββββββββ
SESSIONS_DIR = os.path.join(os.path.dirname(__file__), "sessions")
# Threading events for blocking mode
_pending_feedback: dict[str, threading.Event] = {}
def _safe_id(session_id: str) -> str:
"""Sanitize session_id to a safe filename component."""
return "".join(c for c in session_id if c.isalnum() or c in "-_") or "default"
def _session_path(session_id: str) -> str:
os.makedirs(SESSIONS_DIR, exist_ok=True)
return os.path.join(SESSIONS_DIR, f"{_safe_id(session_id)}.json")
def _load(session_id: str) -> dict:
path = _session_path(session_id)
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# Migrate legacy string entries to {prompt, summary} dicts
entries = data.get("entries", [])
migrated = False
for i, entry in enumerate(entries):
if isinstance(entry, str):
entries[i] = {"prompt": entry, "summary": entry}
migrated = True
if migrated:
data["entries"] = entries
logger.info(f"[Session Memory] Migrated {sum(1 for e in entries if isinstance(e, dict))} legacy entries in '{session_id}'.")
return data
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"[Session Memory] Could not read {path}: {e}")
return {"run_count": 0, "entries": [], "feedback": {}}
def _save(session_id: str, data: dict) -> None:
path = _session_path(session_id)
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError as e:
logger.error(f"[Session Memory] Could not write {path}: {e}")
def _parse_prompts_from_entry(entry_text: str) -> list[dict]:
"""Extract prompt headers, concepts, and full text from a run entry string.
Supports three formats (tried in order):
1. XML-style ``text`` blocks (Visual DNA Synthesizer)
2. Legacy ``PROMPT N:`` markers
3. Fallback: whole entry treated as a single Prompt 1
"""
prompts = []
# ββ Strategy 1: XML-style tags ββββββββββββββββββββββββββ
xml_blocks = re.findall(r'\s*(.*?)\s*', entry_text, re.DOTALL)
if xml_blocks:
for i, block in enumerate(xml_blocks, start=1):
prompts.append({
"number": i,
"concept": f"Prompt {i}",
"fullText": block.strip(),
})
return prompts
# ββ Strategy 2: PROMPT N: markers ββββββββββββββββββββββββββββββββ
prompt_blocks = re.split(r'(?=PROMPT \d+:)', entry_text)
# Markers that indicate the start of a trailing summary section (not prompt content)
_SUMMARY_MARKERS = re.compile(
r'^(SESSION\s+TALLY|TALLY|SUMMARY|SESSION_FEEDBACK|RUN\s+\d+:)', re.MULTILINE
)
for block in prompt_blocks:
m = re.match(r'PROMPT (\d+):', block)
if m:
prompt_num = int(m.group(1))
concept_match = re.search(r'concept:\s*(.+)', block)
concept = (concept_match.group(1).strip()
if concept_match else f"Prompt {prompt_num}")
# Trim trailing session-level sections from the block
full_text = block
trail = _SUMMARY_MARKERS.search(full_text)
if trail:
full_text = full_text[:trail.start()]
full_text = full_text.strip()
prompts.append({
"number": prompt_num,
"concept": concept,
"fullText": full_text,
})
if prompts:
return prompts
# ββ Fallback: whole entry as Prompt 1 ββββββββββββββββββββββββββββ
if entry_text.strip():
full_text = entry_text.strip()
trail = _SUMMARY_MARKERS.search(full_text)
if trail:
full_text = full_text[:trail.start()]
prompts.append({
"number": 1,
"concept": "Result",
"fullText": full_text.strip()
})
return prompts
# ββ Thumbnail helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
def _session_images_dir(session_id: str) -> str:
"""Get the image storage directory for a session."""
return os.path.join(SESSIONS_DIR, _safe_id(session_id))
def _save_thumbnails(session_id, run_number, num_prompts, batch_count,
images_tensor):
"""Save batch images as downscaled JPEG thumbnails.
Layout: sessions/{sid}/run_{N}/prompt_{P}/img_{K}.jpg
where K is the batch/pass index (0-based).
Image order in tensor: [p1, p2, ..., pN, p1, p2, ..., pN, ...]
distributed round-robin across prompts.
With ``batch_count > 1`` ComfyUI **re-executes the entire graph** for
each batch pass, sending the *full* image tensor every time. We must
avoid saving duplicate images. Strategy:
* On the **first call** (prompt dirs are empty) β save all images.
* On **subsequent calls** (prompt dirs already populated) β compute
how many images *per prompt* this call would add. If the folder
already holds ``>= expected_per_prompt`` images, skip entirely
(the images are duplicates from a graph re-execution).
"""
import numpy as np
from PIL import Image as PILImage
base_dir = _session_images_dir(session_id)
run_dir = os.path.join(base_dir, f"run_{run_number}")
B = images_tensor.shape[0]
logger.info(
f"[Session Feedback] Saving thumbnails: B={B}, "
f"num_prompts={num_prompts}, batch_count_widget={batch_count}"
)
# The user-specified batch_count is the authoritative expected number
# of images per prompt. Use it as the dedup threshold:
# - Combined tensor (B = num_prompts Γ batch_count): first call saves
# everything correctly; subsequent re-executions are skipped.
# - Partial batches (B = num_prompts per call): each call appends
# until batch_count is reached, then subsequent calls are skipped.
expected_per_prompt = max(1, batch_count)
first_prompt_dir = os.path.join(run_dir, "prompt_1")
if os.path.isdir(first_prompt_dir):
existing_count = len([
f for f in os.listdir(first_prompt_dir)
if f.startswith("img_") and f.endswith(".jpg")
])
if existing_count >= expected_per_prompt:
logger.info(
f"[Session Feedback] Prompt_1 already has {existing_count} "
f"images (>= {expected_per_prompt} expected per batch_count). "
f"Skipping duplicate re-execution."
)
return
saved = 0
# Distribute images round-robin: image[i] β prompt (i % num_prompts)
for i in range(B):
p = i % num_prompts # 0-based prompt index
prompt_dir = os.path.join(run_dir, f"prompt_{p + 1}")
os.makedirs(prompt_dir, exist_ok=True)
# Find next available index for this prompt
existing = [
f for f in os.listdir(prompt_dir)
if f.startswith("img_") and f.endswith(".jpg")
]
next_idx = len(existing)
# Get single image [H, W, C]
img_t = images_tensor[i]
h, w = img_t.shape[0], img_t.shape[1]
# Downscale to max 1 megapixel
current_mp = (h * w) / 1_000_000
if current_mp > 1.0:
scale = (1.0 / current_mp) ** 0.5
new_h = max(1, int(h * scale))
new_w = max(1, int(w * scale))
else:
new_h, new_w = h, w
img_np = (img_t.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil_img = PILImage.fromarray(img_np)
if pil_img.mode != "RGB":
pil_img = pil_img.convert("RGB")
if (new_h, new_w) != (h, w):
pil_img = pil_img.resize((new_w, new_h), PILImage.LANCZOS)
out_path = os.path.join(prompt_dir, f"img_{next_idx}.jpg")
pil_img.save(out_path, "JPEG", quality=85)
saved += 1
logger.debug(
f"[Session Feedback] image[{i}] β prompt_{p+1}/img_{next_idx}.jpg"
)
logger.info(
f"[Session Feedback] Saved {saved} thumbnails for Run {run_number} "
f"in '{session_id}'."
)
def _delete_session_images(session_id: str) -> int:
"""Delete all thumbnail images for a session. Returns count removed."""
img_dir = _session_images_dir(session_id)
if os.path.isdir(img_dir):
# Count files before deletion
count = sum(len(files) for _, _, files in os.walk(img_dir))
shutil.rmtree(img_dir, ignore_errors=True)
logger.info(
f"[Session Memory] Deleted {count} thumbnail images "
f"for session '{session_id}'."
)
return count
return 0
# ββ Feedback formatting βββββββββββββββββββββββββββββββββββββββββββββββ
def _format_feedback_block(feedback: dict) -> str:
"""Format feedback into a SESSION_FEEDBACK block for the system prompt.
Only includes actionable feedback:
β
1-2 -> AVOID directive (negative constraint)
β
4-5 -> REPLICATE directive (positive reference)
β
3 -> omitted (neutral)
β
0 -> omitted (unrated)
"""
if not feedback:
return ""
lines = []
for run_key in sorted(feedback.keys(), key=lambda x: int(x)):
run_data = feedback[run_key]
prompts_fb = run_data.get("prompts", {})
run_lines = []
for p_key in sorted(prompts_fb.keys(), key=lambda x: int(x)):
p_data = prompts_fb[p_key]
rating = p_data.get("rating", 0)
notes = p_data.get("notes", "").strip()
if rating == 0 or rating == 3:
continue
stars = "\u2605" * rating + "\u2606" * (5 - rating)
directive = "AVOID" if rating <= 2 else "REPLICATE"
line = f" PROMPT {p_key} ({stars} \u2014 {directive})"
if notes:
line += f': "{notes}"'
run_lines.append(line)
if run_lines:
lines.append(f"RUN {run_key}:")
lines.extend(run_lines)
if not lines:
return ""
return "SESSION_FEEDBACK:\n" + "\n".join(lines) + "\n"
# ββ API Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@PromptServer.instance.routes.get("/session_feedback/load")
async def _api_load_feedback(request):
"""Load run data + existing feedback for the frontend editor.
The feedback editor shows the PROMPT text (not the summary).
"""
session_id = request.query.get("session_id", "default")
run_number = request.query.get("run_number", "")
session = _load(session_id)
entries = session.get("entries", [])
run_count = session.get("run_count", 0)
feedback = session.get("feedback", {})
if run_count == 0:
return web.json_response({
"run_count": 0,
"selected_run": 0,
"prompts": [],
"feedback": {},
})
# Determine which run to show
if run_number and run_number.isdigit():
sel_run = int(run_number)
else:
sel_run = run_count
sel_run = max(1, min(sel_run, run_count))
idx = sel_run - 1
entry = entries[idx] if idx < len(entries) else {}
# Use the prompt field for display in the feedback editor
if isinstance(entry, dict):
entry_text = entry.get("prompt", "")
else:
entry_text = entry # legacy string fallback
prompts = _parse_prompts_from_entry(entry_text)
run_feedback = feedback.get(str(sel_run), {}).get("prompts", {})
return web.json_response({
"run_count": run_count,
"selected_run": sel_run,
"prompts": prompts,
"feedback": run_feedback,
})
@PromptServer.instance.routes.post("/session_feedback/save")
async def _api_save_feedback(request):
"""Save feedback from the frontend editor to the session file."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
session_id = body.get("session_id", "default")
run_number = str(body.get("run_number", ""))
prompts_fb = body.get("prompts", {})
if not run_number:
return web.json_response({"error": "run_number required"}, status=400)
session = _load(session_id)
feedback = session.get("feedback", {})
if run_number not in feedback:
feedback[run_number] = {"prompts": {}}
for p_key, p_data in prompts_fb.items():
feedback[run_number]["prompts"][str(p_key)] = {
"rating": int(p_data.get("rating", 0)),
"notes": str(p_data.get("notes", "")),
}
session["feedback"] = feedback
_save(session_id, session)
deleted_thumbs = body.get("deleted_thumbs", [])
if deleted_thumbs:
del_count = 0
for thumb_rel in deleted_thumbs:
# Reconstruct absolute paths safely
safe_rel = thumb_rel.lstrip("/\\")
if ".." in safe_rel:
continue
abs_path = os.path.join(SESSIONS_DIR, safe_rel)
if os.path.isfile(abs_path):
try:
os.remove(abs_path)
del_count += 1
except OSError as e:
logger.warning(f"[Session Feedback] Could not delete thumb {thumb_rel}: {e}")
if del_count > 0:
logger.info(f"[Session Feedback] Permanently deleted {del_count} selected thumbnails.")
logger.info(
f"[Session Feedback] Saved feedback for Run {run_number} "
f"in session '{session_id}' ({len(prompts_fb)} prompts)."
)
return web.json_response({"status": "ok"})
@PromptServer.instance.routes.post("/session_feedback/resume")
async def _api_resume_feedback(request):
"""Resume blocked execution after user saves feedback."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
node_id = str(body.get("node_id", ""))
event = _pending_feedback.get(node_id)
if event:
event.set()
logger.info(f"[Session Feedback] Resumed execution for node {node_id}.")
return web.json_response({"status": "resumed"})
return web.json_response({"status": "not_found"})
@PromptServer.instance.routes.post("/session_feedback/delete_run")
async def _api_delete_run(request):
"""Delete a specific run and its thumbnails."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
session_id = body.get("session_id", "default")
run_number = str(body.get("run_number", ""))
if not run_number:
return web.json_response({"error": "run_number required"}, status=400)
session = _load(session_id)
del_run_int = int(run_number)
entries = session.get("entries", [])
run_count = session.get("run_count", len(entries))
idx = del_run_int - 1
# 1. Update entries
if 0 <= idx < len(entries):
entries.pop(idx)
session["run_count"] = max(0, run_count - 1)
# Renumber the headers inside the entries to maintain 1..N contiguity
for i in range(len(entries)):
if isinstance(entries[i], dict):
for field in ("prompt", "summary"):
if field in entries[i]:
entries[i][field] = re.sub(
r'^---\s*RUN\s+\d+\s*---\n',
f'--- RUN {i+1} ---\n',
entries[i][field], count=1, flags=re.MULTILINE,
)
else:
entries[i] = re.sub(r'^---\s*RUN\s+\d+\s*---\n', f'--- RUN {i+1} ---\n', entries[i], count=1, flags=re.MULTILINE)
session["entries"] = entries
# 2. Update feedback keys
feedback = session.get("feedback", {})
new_feedback = {}
for k_str, v in feedback.items():
try:
k_int = int(k_str)
if k_int < del_run_int:
new_feedback[str(k_int)] = v
elif k_int > del_run_int:
new_feedback[str(k_int - 1)] = v
except ValueError:
pass
session["feedback"] = new_feedback
_save(session_id, session)
# 3. Delete the target thumbnail folder
safe_sid = _safe_id(session_id)
del_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{del_run_int}")
if os.path.isdir(del_dir):
shutil.rmtree(del_dir, ignore_errors=True)
# 4. Shift subsequent thumbnail folders down by 1
for k in range(del_run_int + 1, run_count + 1):
old_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{k}")
new_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{k-1}")
if os.path.isdir(old_dir):
try:
os.rename(old_dir, new_dir)
except OSError as e:
logger.error(f"[Session Feedback] Rename failed: {e}")
logger.info(f"[Session Feedback] Deleted Run {del_run_int} in session '{session_id}' and shifted remaining runs.")
return web.json_response({"status": "deleted"})
@PromptServer.instance.routes.post("/session_feedback/delete_session")
async def _api_delete_session(request):
"""Completely delete a session JSON and all its image assets."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
session_id = body.get("session_id", "default")
if not session_id:
return web.json_response({"error": "Session ID required"}, status=400)
safe_sid = _safe_id(session_id)
# 1. Delete the JSON file
json_path = os.path.join(SESSIONS_DIR, f"{safe_sid}.json")
if os.path.isfile(json_path):
os.remove(json_path)
# 2. Delete the entire thumbnails folder
img_dir = os.path.join(SESSIONS_DIR, safe_sid)
if os.path.isdir(img_dir):
shutil.rmtree(img_dir, ignore_errors=True)
logger.warning(f"[Session Feedback] NUKED session '{session_id}' entirely.")
return web.json_response({"status": "deleted"})
@PromptServer.instance.routes.post("/session_feedback/rename_session")
async def _api_rename_session(request):
"""Rename a session JSON and its thumbnail folder."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
old_id = body.get("old_session_id", "")
new_id = body.get("new_session_id", "")
if not old_id or not new_id:
return web.json_response({"error": "old_session_id and new_session_id required"}, status=400)
safe_old = _safe_id(old_id)
safe_new = _safe_id(new_id)
if safe_old == safe_new:
return web.json_response({"status": "renamed"}) # Nothing to do
old_json = os.path.join(SESSIONS_DIR, f"{safe_old}.json")
new_json = os.path.join(SESSIONS_DIR, f"{safe_new}.json")
if os.path.exists(new_json):
return web.json_response({"error": f"Session '{new_id}' already exists."}, status=400)
# Rename JSON
if os.path.isfile(old_json):
try:
os.rename(old_json, new_json)
except OSError as e:
return web.json_response({"error": f"Could not rename file: {e}"}, status=500)
else:
# Create an empty session if it didn't exist before renaming
_save(new_id, {"run_count": 0, "entries": [], "feedback": {}})
# Rename images directory
old_dir = os.path.join(SESSIONS_DIR, safe_old)
new_dir = os.path.join(SESSIONS_DIR, safe_new)
if os.path.isdir(old_dir):
try:
os.rename(old_dir, new_dir)
except OSError as e:
pass # Non-critical if it fails or doesn't exist
logger.info(f"[Session Feedback] Renamed session from '{old_id}' to '{new_id}'.")
return web.json_response({"status": "renamed"})
@PromptServer.instance.routes.get("/session_feedback/thumbnails")
async def _api_list_thumbnails(request):
"""List available thumbnail images for a specific run."""
session_id = request.query.get("session_id", "default")
run_number = request.query.get("run_number", "1")
safe_sid = _safe_id(session_id)
run_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{run_number}")
result = {}
if os.path.isdir(run_dir):
for entry in sorted(os.listdir(run_dir)):
if not entry.startswith("prompt_"):
continue
prompt_path = os.path.join(run_dir, entry)
if not os.path.isdir(prompt_path):
continue
p_num = entry.replace("prompt_", "")
imgs = sorted(
f for f in os.listdir(prompt_path)
if f.lower().endswith((".jpg", ".jpeg", ".png"))
)
result[p_num] = [
f"/session_feedback/thumb?path="
f"{safe_sid}/run_{run_number}/{entry}/{img}"
for img in imgs
]
return web.json_response(result)
@PromptServer.instance.routes.get("/session_feedback/thumb")
async def _api_serve_thumbnail(request):
"""Serve a single thumbnail image file."""
rel_path = request.query.get("path", "")
if not rel_path:
return web.Response(status=400, text="Missing path")
full_path = os.path.realpath(os.path.join(SESSIONS_DIR, rel_path))
sessions_real = os.path.realpath(SESSIONS_DIR)
# Security: prevent directory traversal
if not full_path.startswith(sessions_real):
return web.Response(status=403, text="Forbidden")
if not os.path.isfile(full_path):
return web.Response(status=404, text="Not found")
return web.FileResponse(full_path)
# ββ Node: Feedback Editor βββββββββββββββββββββββββββββββββββββββββββββ
class SessionFeedbackEditorNode:
"""
β Session Feedback Editor
Placed AFTER the Writer. Shows the latest Run for the director to
rate (1-5 stars) and annotate each prompt. Optionally receives a
batch of images to save as thumbnails for visual preview.
Two modes:
- non_blocking: execution completes, user annotates at leisure
- blocking: execution pauses until user clicks Save
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"session_id": ("STRING", {
"default": "default",
"tooltip": "Session identifier. Must match Reader/Writer.",
}),
"mode": (["non_blocking", "blocking"], {
"default": "non_blocking",
"tooltip": "blocking = pauses workflow until you save feedback. "
"non_blocking = workflow finishes, annotate later.",
}),
"num_prompts": ("INT", {
"default": 4,
"min": 1,
"max": 20,
"step": 1,
"tooltip": "Number of prompts per run (e.g. 4).",
}),
"batch_count": ("INT", {
"default": 1,
"min": 1,
"max": 50,
"step": 1,
"tooltip": "Times each prompt was sampled. "
"Total images = num_prompts Γ batch_count.",
}),
},
"optional": {
"run_summary": ("STRING", {
"forceInput": True,
"tooltip": "Connect from Writer to ensure execution order.",
}),
"thumbnail_images": ("IMAGE", {
"tooltip": "Batch of images from sampler. "
"Total = num_prompts Γ batch_count.",
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "process"
CATEGORY = "\U0001f9e0 Memory"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def process(self, session_id: str = "default", mode: str = "non_blocking",
num_prompts: int = 4, batch_count: int = 1,
run_summary: str = "", thumbnail_images=None,
unique_id=None):
session = _load(session_id)
run_count = session.get("run_count", 0)
if run_count == 0:
logger.info("[Session Feedback Editor] No runs yet.")
return {"ui": {"status": ["no_runs"]}}
# Save thumbnails if images provided
if thumbnail_images is not None:
_save_thumbnails(
session_id, run_count, num_prompts, batch_count,
thumbnail_images,
)
# Get latest run prompts + existing feedback
# Feedback editor shows the PROMPT text (not summary)
entries = session.get("entries", [])
latest_entry = entries[-1] if entries else {}
if isinstance(latest_entry, dict):
entry_text = latest_entry.get("prompt", "")
else:
entry_text = latest_entry # legacy string fallback
prompts = _parse_prompts_from_entry(entry_text)
feedback = session.get("feedback", {})
run_feedback = feedback.get(str(run_count), {}).get("prompts", {})
# Notify frontend via websocket
PromptServer.instance.send_sync("session_feedback_update", {
"node_id": str(unique_id),
"session_id": session_id,
"run_count": run_count,
"selected_run": run_count,
"prompts": prompts,
"feedback": run_feedback,
"mode": mode,
})
if mode == "blocking":
logger.info(
f"[Session Feedback Editor] Blocking β waiting for feedback "
f"on Run {run_count} (node {unique_id})..."
)
event = threading.Event()
_pending_feedback[str(unique_id)] = event
try:
# Wait up to 10 minutes
timeout = 600
start = time.time()
while not event.is_set():
if time.time() - start > timeout:
logger.warning(
"[Session Feedback Editor] Timeout β "
"continuing without feedback."
)
break
event.wait(timeout=1.0)
finally:
_pending_feedback.pop(str(unique_id), None)
logger.info("[Session Feedback Editor] Resumed after feedback.")
else:
logger.info(
f"[Session Feedback Editor] Non-blocking β Run {run_count} "
f"ready for feedback in UI."
)
return {"ui": {"status": ["done"]}}
# ββ Node: Reader βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class SessionMemoryReaderNode:
"""
π§ Session Memory Reader
Placed at the start of the workflow. Reads the accumulated session log
AND feedback from disk, outputs it for injection into the system prompt.
"""
@classmethod
def INPUT_TYPES(cls):
sessions = _get_available_sessions()
return {
"required": {
"existing_session": (["[CREATE NEW]"] + sessions, {
"default": sessions[-1] if sessions else "[CREATE NEW]",
"tooltip": "Select an existing session, or [CREATE NEW] to use the text box below."
}),
"new_session_name": ("STRING", {
"default": "new_session",
"tooltip": "If [CREATE NEW] is selected above, this will be your new session_id."
}),
},
}
RETURN_TYPES = ("STRING", "INT", "STRING")
RETURN_NAMES = ("session_memory", "run_count", "session_id")
FUNCTION = "read"
CATEGORY = "\U0001f9e0 Memory"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def read(self, existing_session="[CREATE NEW]", new_session_name="new_session"):
if existing_session == "[CREATE NEW]":
session_id = new_session_name.strip()
else:
session_id = existing_session
if not session_id:
session_id = "unnamed_session"
session = _load(session_id)
entries = session.get("entries", [])
run_count = session.get("run_count", 0)
feedback = session.get("feedback", {})
# Reader returns the SUMMARY field (not the prompt).
# When summary is empty / placeholder, try extracting
# tags from the prompt field (Visual DNA Synthesizer format).
_PLACEHOLDER = "(No summary provided for this run)"
summaries = []
for entry in entries:
if isinstance(entry, dict):
summary_text = entry.get("summary", "")
# Detect placeholder / empty summary β extract tags
stripped = summary_text.strip().lstrip("- RUN 0123456789\n")
if not stripped or stripped == _PLACEHOLDER:
prompt_text = entry.get("prompt", "")
xml_sums = re.findall(
r'\s*(.*?)\s*',
prompt_text, re.DOTALL,
)
if xml_sums:
summary_text = "\n".join(s.strip() for s in xml_sums)
summaries.append(summary_text)
else:
summaries.append(entry) # legacy string fallback
session_memory = "\n".join(summaries) if summaries else ""
# Append feedback block if any
feedback_block = _format_feedback_block(feedback)
if feedback_block:
session_memory = session_memory + "\n" + feedback_block
logger.info(
f"[Session Memory Reader] Session '{session_id}' β "
f"{run_count} runs, {len(session_memory)} chars"
f"{', with feedback' if feedback_block else ''}."
)
return (session_memory, run_count, session_id)
# ββ Node: Writer βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class SessionMemoryWriterNode:
"""
πΎ Session Memory Writer
Placed at the end of the workflow, after the Gemini Summarizer.
Appends the structured run summary to the session file on disk.
Outputs session_id so it can chain to the Feedback Editor.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"session_id": ("STRING", {
"default": "default",
"tooltip": "Session identifier. Must match Reader/Feedback.",
}),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "The raw prompt sent to the model. Displayed in the Feedback Editor.",
}),
"summary": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Structured summary from the Gemini Summarizer. Returned by the Reader.",
}),
},
"optional": {
"max_runs": ("INT", {
"default": 0,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Sliding window: keep only the last N entries. "
"0 = unlimited.",
}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("run_summary", "session_id")
OUTPUT_NODE = True
FUNCTION = "write"
CATEGORY = "\U0001f9e0 Memory"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def write(
self,
session_id: str = "default",
prompt: str = "",
summary: str = "",
max_runs: int = 0,
):
prompt_stripped = prompt.strip()
summary_stripped = summary.strip()
if not prompt_stripped:
prompt_stripped = "(No prompt provided for this run)"
if not summary_stripped:
summary_stripped = "(No summary provided for this run)"
logger.info(
f"[Session Memory Writer] Empty summary β creating a new run with placeholder in session '{session_id}'."
)
session = _load(session_id)
run_count = session.get("run_count", 0) + 1
entries: list = session.get("entries", [])
feedback = session.get("feedback", {})
entry = {
"prompt": f"--- RUN {run_count} ---\n{prompt_stripped}\n",
"summary": f"--- RUN {run_count} ---\n{summary_stripped}\n",
}
entries.append(entry)
# Sliding window
if max_runs > 0 and len(entries) > max_runs:
trimmed = len(entries) - max_runs
entries = entries[-max_runs:]
logger.info(
f"[Session Memory Writer] Trimmed {trimmed} oldest entries "
f"(window={max_runs})."
)
_save(session_id, {
"run_count": run_count,
"entries": entries,
"feedback": feedback,
})
logger.info(
f"[Session Memory Writer] Written Run {run_count} to session "
f"'{session_id}' (prompt={len(prompt_stripped)} chars, "
f"summary={len(summary_stripped)} chars, {len(entries)} entries)."
)
return (summary, session_id)
def _get_available_sessions():
os.makedirs(SESSIONS_DIR, exist_ok=True)
sessions = []
for f in os.listdir(SESSIONS_DIR):
if f.endswith(".json"):
sessions.append(f[:-5])
return sorted(sessions) if sessions else ["default"]
class SessionIDSelectorNode:
"""
Select an existing session id from a list, or type a new one to start fresh.
"""
@classmethod
def INPUT_TYPES(cls):
sessions = _get_available_sessions()
return {
"required": {
"existing_session": (["[CREATE NEW]"] + sessions, {
"default": sessions[-1] if sessions else "[CREATE NEW]",
"tooltip": "Select an existing session, or [CREATE NEW] to use the text box below."
}),
"new_session_name": ("STRING", {
"default": "new_session",
"tooltip": "If [CREATE NEW] is selected above, this will be your new session_id."
}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("session_id",)
FUNCTION = "select_id"
CATEGORY = "π§ Memory"
def select_id(self, existing_session, new_session_name):
if existing_session == "[CREATE NEW]":
final_id = new_session_name.strip()
else:
final_id = existing_session
return (final_id,)
# ββ Registration βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
NODE_CLASS_MAPPINGS = {
"SessionIDSelector": SessionIDSelectorNode,
"SessionFeedbackEditor": SessionFeedbackEditorNode,
"SessionMemoryReader": SessionMemoryReaderNode,
"SessionMemoryWriter": SessionMemoryWriterNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SessionIDSelector": "ποΈ (DEPRECATED) Session ID Selector",
"SessionFeedbackEditor": "\u2b50 Session Feedback Editor",
"SessionMemoryReader": "\U0001f9e0 Session Memory Reader",
"SessionMemoryWriter": "\U0001f4be Session Memory Writer",
}