<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[CrushOn AI Roleplay Lab]]></title><description><![CDATA[CrushOn AI Roleplay Lab]]></description><link>https://crushonai.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 07:28:35 GMT</lastBuildDate><atom:link href="https://crushonai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[CrushOn AI vs SillyTavern and KoboldCpp: Choosing an AI Roleplay Setup in 2026]]></title><description><![CDATA[Compare CrushOn AI, SillyTavern and KoboldCpp for hosted or local roleplay: setup, costs, privacy, exports and a JavaScript budget worksheet.]]></description><link>https://crushonai.hashnode.dev/crushon-ai-sillytavern-koboldcpp-ai-roleplay-setup-2026</link><guid isPermaLink="true">https://crushonai.hashnode.dev/crushon-ai-sillytavern-koboldcpp-ai-roleplay-setup-2026</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[CrushOn AI Editorial]]></dc:creator><pubDate>Thu, 22 Oct 2026 03:44:46 GMT</pubDate><content:encoded><![CDATA[<p>Choose <strong>CrushOn AI</strong> when your goal is to create an original character and start hosted roleplay without administering a model backend. Choose <strong>SillyTavern</strong> when configuring the character workspace and its backend is part of the job. Choose <strong>KoboldCpp</strong> when you want to run a compatible model locally, with its bundled interface or a separate frontend. These choices solve different problems; they are not three interchangeable chat subscriptions.</p>
<p><em>Disclosure: Published by the CrushOn AI brand with AI-assisted writing and source review. This is a documentation-based workflow comparison, not an independent three-product performance test. Documentation checked October 21, 2026, Pacific Time.</em></p>
<p>For a writing-first starting point, <a href="https://chat.crushon.ai/ai-roleplay">explore CrushOn AI's SFW roleplay guide</a>. For a technical decision, work through the responsibilities, budget and portability checks below. The accompanying JavaScript runs locally and makes no network requests.</p>
<h2>Compare complete setups rather than product names</h2>
<p>A hosted app supplies the interface and the managed generation service. A frontend organizes prompts and conversations but still needs somewhere to send them. A backend loads or accesses a model and produces the response.</p>
<p><a href="https://docs.sillytavern.app/usage/api-connections/">SillyTavern's API documentation</a> describes connections to local and cloud model services. Its interface can assemble character information, conversation and other prompt material for the selected API. Installing the frontend does not, by itself, install a model.</p>
<p><a href="https://github.com/LostRuins/koboldcpp">KoboldCpp</a> runs compatible GGUF models and exposes an API; it also includes KoboldAI Lite. SillyTavern is therefore optional in a KoboldCpp setup. Use the bundled interface when it covers your writing needs, and add another frontend when its workflow earns the additional configuration.</p>
<table>
<thead>
<tr>
<th>Complete setup</th>
<th>Who supplies generation</th>
<th>What you take responsibility for</th>
</tr>
</thead>
<tbody><tr>
<td>CrushOn AI</td>
<td>The hosted service</td>
<td>Character setup, available in-app settings, account and plan choices</td>
</tr>
<tr>
<td>SillyTavern plus cloud API</td>
<td>Your selected provider</td>
<td>Frontend, connection settings, provider access and usage budget</td>
</tr>
<tr>
<td>SillyTavern plus local KoboldCpp</td>
<td>The model on your machine</td>
<td>Both applications, model files, settings, hardware and backups</td>
</tr>
<tr>
<td>KoboldCpp with bundled Lite</td>
<td>The model on your machine</td>
<td>Backend, model files, hardware, story settings and backups</td>
</tr>
</tbody></table>
<p>The important question is not whether one logo has “better AI.” It is which responsibilities you want to own. A writer who dislikes server configuration and a developer who needs a specific local model can reasonably choose different routes.</p>
<h2>What CrushOn AI removes from the setup process</h2>
<p>CrushOn's practical advantage is a short path from an authored character to a hosted conversation. You do not need to select a GGUF file, allocate GPU layers or connect a separate inference endpoint before trying a scene.</p>
<p>Its <a href="https://aiwiki.crushon.ai/wiki/Basic_Single_character_guide">character guide</a> distinguishes Personality, Greeting, Scenario and Example Conversation. Introduction is profile-facing rather than a substitute for behavioral setup. Character visibility and definition visibility are separate choices, so inspect both when preparing a private draft.</p>
<p>Those distinctions are useful even to a developer. They let you change the opening scene without treating it as permanent personality, or show an example of the character's voice without adding an entire past conversation to the profile.</p>
<p>The <a href="https://aiwiki.crushon.ai/wiki/Chat_Features">chat tools guide</a> documents reply editing and separately editable pins. Correcting a sentence and keeping a compact scene fact are different operations. Neither is a guarantee that every later reply will follow the correction.</p>
<p>Start with the actual bottleneck: if you want to write rather than maintain infrastructure, this hosted workflow is a strong reason to shortlist CrushOn. If you require local inference or ownership of the inference stack, use the local route instead of trying to turn a hosted character app into one.</p>
<h2>When a configurable stack is worth the work</h2>
<p>SillyTavern makes sense when choosing the backend, organizing character material or managing chat files is central to the project. A cloud connection avoids running the model on your machine, but it still requires provider credentials, connection configuration and cost monitoring.</p>
<p>KoboldCpp is relevant when you want local generation and have hardware suited to the model you select. A model's size, quantization, context setting and hardware configuration all influence what will fit and how it will behave. “Runs locally” is not a useful latency estimate by itself.</p>
<p>Before downloading a large model, write down your minimum usable setup: machine specifications, operating system, model license, anticipated context, acceptable wait per reply and whether the session must work without cloud inference. Keep an observed result alongside each assumption after trying the configuration.</p>
<p>Do not start with the largest advertised context window. Your character definition, conversation and injected material compete for the available prompt space, and processing a longer prompt has a workload cost. First determine whether a small, clearly structured scene works; then expand the world information you actually need.</p>
<h2>Calculate cost with your workload and your own numbers</h2>
<p>“Free frontend” and “free local software” do not describe the full budget. Conversely, a subscription is not automatically poor value because another route has no subscription fee.</p>
<p>Compare a fixed planning period and keep two totals separate:</p>
<ul>
<li><strong>Cash outlay:</strong> new equipment, recurring service charges, usage charges and incremental electricity.</li>
<li><strong>Time allowance:</strong> setup and maintenance hours multiplied by the value you choose for that time.</li>
</ul>
<p>Time allowance is not an invoice. Someone who enjoys configuration may set its value to zero; someone evaluating a work project may reasonably include it. Recording it separately makes that judgment visible.</p>
<p>For cloud usage, estimate the whole billable prompt, not only the newest typed message. Repeated history and generated outputs can matter under token billing. For hosted products, check the selected plan, model eligibility, regeneration rules and billing period. Use <a href="https://crushon.ai/pricing">CrushOn's current pricing page</a> for the account offer rather than copying an old quota from a comparison post.</p>
<p>For existing hardware, include only the incremental equipment cost relevant to your decision. Use the additional power consumption attributable to the workload, not a power-supply label. If you include taxes, credits, paid extensions or backup storage, enter them consistently in the appropriate cash category.</p>
<p>Here is a local calculator. Save this as <code>worksheet.mjs</code>. Use one currency throughout; <code>null</code> means unknown, while <code>0</code> means explicitly zero.</p>
<pre><code class="language-javascript">export function budget(input) {
  const fields = ["months", "upfront", "monthlyFee", "monthlyUsage",
    "watts", "hoursPerMonth", "pricePerKwh", "setupHours",
    "maintenanceHours", "hourlyTimeValue"];
  const unknown = fields.filter(k =&gt; input[k] == null);
  for (const k of fields.filter(k =&gt; input[k] != null)) {
    if (typeof input[k] !== "number" || !Number.isFinite(input[k]) || input[k] &lt; 0) {
      throw new TypeError(`${k} must be a finite nonnegative number or null`);
    }
  }
  if (input.months != null &amp;&amp; (!Number.isInteger(input.months) || input.months &lt; 1)) {
    throw new RangeError("months must be a positive integer");
  }
  if (unknown.length) return {status: "incomplete", unknown};
  const energy = input.watts / 1000 * input.hoursPerMonth * input.pricePerKwh;
  const cash = input.upfront + input.months *
    (input.monthlyFee + input.monthlyUsage + energy);
  const time = (input.setupHours + input.months * input.maintenanceHours) *
    input.hourlyTimeValue;
  const round = n =&gt; {
    const scaled = (n + Number.EPSILON) * 100;
    if (!Number.isFinite(scaled)) throw new RangeError("Amount is too large to round to cents");
    return Math.round(scaled) / 100;
  };
  if (![energy, cash, time, cash + time].every(Number.isFinite)) {
    throw new RangeError("The calculated budget exceeds the supported number range");
  }
  return {status: "complete", monthlyEnergyCost: round(energy),
    cashOutlay: round(cash), timeAllowance: round(time),
    planningTotal: round(cash + time)};
}
</code></pre>
<p>Save the next block as <code>demo.mjs</code> in the same folder and run <code>node demo.mjs</code> with a current Node.js runtime.</p>
<pre><code class="language-javascript">import {budget} from "./worksheet.mjs";

// Invented USD inputs, not product prices or measured hardware use.
const common = {months: 12, monthlyUsage: 0, pricePerKwh: 0.20,
  hoursPerMonth: 20, hourlyTimeValue: 20};
const hosted = {...common, upfront: 0, monthlyFee: 15, watts: 0,
  setupHours: 0.5, maintenanceHours: 0.1};
const local = {...common, upfront: 400, monthlyFee: 0, watts: 200,
  setupHours: 3, maintenanceHours: 1};
console.log({hosted: budget(hosted), local: budget(local)});
console.log(budget({...hosted, monthlyFee: null}));
</code></pre>
<p>The hypothetical hosted inputs produce <strong>$180 cash outlay plus $34 time allowance</strong> over twelve months. The local inputs produce <strong>$409.60 cash outlay plus $300 time allowance</strong>. These are examples of arithmetic, not prices for CrushOn, SillyTavern or KoboldCpp, and not equivalent-quality service quotes. The zero hosted wattage excludes incremental local inference power; ordinary client-device usage is outside both examples.</p>
<p>Changing the unknown price to <code>null</code> returns an incomplete result instead of silently making that route free. The calculator assumes a flat monthly workload and does not model discounts, hardware resale, financing or changing rates. Replace the inputs or use separate periods when those details matter.</p>
<h2>Privacy depends on the actual data path</h2>
<p>A local frontend connected to a cloud model still sends the generation request to that provider. A locally running model can remove that particular cloud inference hop, but extensions, remote services, synchronization and exposed endpoints can change the overall picture.</p>
<p>Draw the route for the setup you will use:</p>
<pre><code class="language-text">Hosted:       browser -&gt; hosted application and its service environment
Cloud stack:  local frontend -&gt; selected remote model provider
Local stack:  local frontend -&gt; local backend -&gt; local model files
</code></pre>
<p>This diagram describes deployment categories, not an audit of a provider's internal systems. For each additional connection, ask what it receives, whether it persists the data and who can access it. Also check who can access the machine and its backups. “On my laptop” is not the same as “inaccessible to anyone else.”</p>
<p>For a private writing exercise, use fictional material first. Keep API credentials and account identifiers out of exported examples. If offline inference is a hard requirement, choose that route before comparing convenience; do not award a hosted app an artificial privacy score to override the requirement.</p>
<h2>Preserve the character separately from the chat archive</h2>
<p>A character definition, a transcript and current scene state are different assets. Keep them separate so a migration does not become an attempt to paste months of dialogue into a single personality field.</p>
<p>Use this new scene with our existing original SFW character, Iris, as a manual mapping exercise:</p>
<blockquote>
<p>Iris Vale is a 32-year-old museum conservator preparing a rain-delayed exhibition. She speaks precisely and warmly, asks before moving artifacts, and distinguishes evidence from uncertainty. The user's sealed case is on the worktable; permission to open it has not been given. Iris may narrate her own actions and speech, but not the user's actions, feelings or decisions.</p>
</blockquote>
<p>In CrushOn, put the enduring behavior in Personality, the immediate situation in Scenario, and the opening exchange in Greeting. An Example Conversation can demonstrate her voice. In a configurable stack, map those same concepts to the character and prompt fields actually supported by the chosen frontend. Do not assume a JSON brief from one tool is a native import file for another.</p>
<p><a href="https://docs.sillytavern.app/usage/core-concepts/chatfilemanagement/">SillyTavern's chat-file guide</a> specifies a useful distinction: JSONL exports retain chat metadata and can be re-imported, but exclude images and file attachments. Plain-text exports lose required metadata and cannot be re-imported as chat files. Check the actual export route, rather than treating every download as a complete backup.</p>
<p><a href="https://github.com/LostRuins/lite.koboldai.net">KoboldAI Lite</a> documents story formats and character-card support. That is a reason to inspect compatibility, not a promise that any proprietary account archive will import intact. Keep the original private export unchanged, work on a copy and restore into a separate test before relying on it.</p>
<h2>Make the setup decision reproducible</h2>
<p>Write a short decision record before experimenting. The <a href="https://github.com/CrushOnAI/character-personality-kit/tree/main/tools/roleplay-setup-worksheet">companion worksheet and methodology</a> provide a blank record and explicit route checks. They do not score writing quality or decide that one product is universally best.</p>
<p>Record five things:</p>
<ol>
<li><strong>Requirements:</strong> local inference, backend choice, setup tolerance and required export assets. If “no configuration” conflicts with “I must choose and run the model,” resolve the tradeoff instead of hiding it in a score.</li>
<li><strong>Configuration:</strong> frontend/backend versions, model and quantization where applicable, context settings and relevant account tier.</li>
<li><strong>Cost assumptions:</strong> a source or a clearly labeled assumption for every calculator input, including zero values.</li>
<li><strong>One initial scene:</strong> use the same authored brief and question, recording the complete first reply before edits or regeneration.</li>
<li><strong>Restore check:</strong> which fields, messages and attachments survive a private export-and-restore attempt.</li>
</ol>
<p>For Iris, ask: “What would you check before we open the case? Do not open it yet.” Inspect whether the case stays closed, whether the reply invents a decision for the user, and whether it distinguishes an observation from a guess. A polished paragraph can still fail one of those checks.</p>
<p>This is a useful acceptance check for your setup, not a long-term memory benchmark. Our separate <a href="https://github.com/CrushOnAI/character-personality-kit/tree/main/studies/iris-personality-20261013">CrushOn prompt study</a> contains six five-turn conversations with one character and model. It is a record you can inspect, including limitations, rather than evidence that an entire platform outperforms another.</p>
<h2>Questions that change the decision</h2>
<h3>Is SillyTavern an alternative to CrushOn AI</h3>
<p>As part of a working frontend-and-backend setup, yes. By itself it is not the same complete service. Include the selected provider or local model when comparing cost, privacy and maintenance.</p>
<h3>Do I need SillyTavern to use KoboldCpp</h3>
<p>No. KoboldCpp includes the Lite interface. Add SillyTavern when its character and conversation-management workflow meets a need you have identified.</p>
<h3>Is local AI roleplay always cheaper</h3>
<p>No. It depends on existing equipment, workload, power and the value of setup time. The worksheet exposes these inputs without pretending different models supply identical output quality.</p>
<h3>Will moving a character preserve its behavior</h3>
<p>Not necessarily. An authored definition does not include the original model's behavior or every hidden context mechanism. Rebuild the intended voice and scene, then inspect fresh replies.</p>
<h2>Choose the work you want to spend time doing</h2>
<p>For a writer who wants to define an original character, start a scene and revise replies, <strong>CrushOn AI is the hosted route we recommend evaluating first</strong>. Its concrete advantage is concentrating your effort on character authoring rather than backend administration. Start with the <a href="https://chat.crushon.ai/ai-roleplay">SFW roleplay entry</a> and use the <a href="https://aiwiki.crushon.ai/wiki/Basic_Single_character_guide">official creation guide</a> to build a private fictional example.</p>
<p>For a project whose requirements include a particular backend, local inference or explicit file management, evaluate the configurable stack. Choose the route that meets those requirements, keep a recoverable copy of the writing, and revisit the decision when the workload or controls actually change.</p>
]]></content:encoded></item><item><title><![CDATA[AI Roleplay Test Logs: Validate Missing Turns and Prompt Versions with JavaScript]]></title><description><![CDATA[Validate CrushOn AI roleplay test logs with JavaScript. Catch missing replies and prompt-version errors with runnable code and sample fixtures.]]></description><link>https://crushonai.hashnode.dev/ai-roleplay-test-logs-missing-turns-prompt-versions-javascript</link><guid isPermaLink="true">https://crushonai.hashnode.dev/ai-roleplay-test-logs-missing-turns-prompt-versions-javascript</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[CrushOn AI Editorial]]></dc:creator><pubDate>Wed, 21 Oct 2026 09:35:30 GMT</pubDate><content:encoded><![CDATA[<p>If you are testing a CrushOn AI character, the first question is not which prompt produced the best line. It is whether your log contains the replies you intended to compare, generated from the character version you meant to test.</p>
<p>CrushOn AI's separate Personality, Greeting, Scenario, and Example Conversation fields provide a practical way to revise a character while keeping other inputs fixed. Our <a href="https://github.com/CrushOnAI/character-personality-kit/tree/main/studies/iris-personality-20261013">published Iris study</a> used those controls for six private SFW conversations and 30 first-generated replies. This tutorial adds a small local JavaScript check around that workflow: identify missing turns, accidental duplicates, changed questions, and mismatched input versions before reviewing the writing.</p>
<p><strong>Disclosure:</strong> This tutorial is prepared for the CrushOn AI brand account with AI assistance. It is product-affiliated, not an independent product ranking. All replies in the software examples below are deliberately authored synthetic fixtures, not new CrushOn AI outputs. The linked October 13 study is a separate real chat record.</p>
<h2>The short answer</h2>
<p>Keep the planned test in a separate manifest. Record each run against that manifest using a run ID, an exact character-input snapshot, an input version, and one entry for each planned question. Validate the structure before calculating any result or describing the test as complete.</p>
<p>A log can be structurally complete while its replies are poor. Conversely, a good-looking excerpt cannot make an incomplete log complete. Those are different checks, and the code below deliberately separates them.</p>
<p>You need Node.js with ES-module support and its built-in test runner; Node.js 22 or later is a convenient choice. The package was executed with Node.js 24.8.0. There are no dependencies, API keys, model calls, or network requests.</p>
<p>The <a href="https://github.com/CrushOnAI/character-personality-kit/tree/main/tools/roleplay-log-validator">complete validator, synthetic fixtures, and 23-check test suite</a> are available in the existing CrushOn AI repository. The code and examples below are also self-contained if you prefer to copy them into a local directory.</p>
<h2>Why a reply count is not enough</h2>
<p>Suppose you plan two fresh conversations with the same character, each containing two questions. Four replies are expected. A spreadsheet showing four nonempty reply cells can still hide a missing question if another answer was pasted twice.</p>
<p>Likewise, an <code>iris-a-v1</code> label does not prove the underlying prompt stayed unchanged. Someone can edit a character's behavior instructions and forget to change its version label. The validator checks the label and exact input text separately.</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>Example problem</th>
<th>What to do with it</th>
</tr>
</thead>
<tbody><tr>
<td>Planned run and question IDs</td>
<td>A1 has Q1 twice and no Q2</td>
<td>Recover the original capture, or report Q2 as missing</td>
</tr>
<tr>
<td>Exact question text</td>
<td>One run adds “Do not speak for me”</td>
<td>Treat it as a changed test, not an equivalent prompt</td>
</tr>
<tr>
<td>Input version and text</td>
<td>The label says v1 but the character was edited</td>
<td>Recover the captured input snapshot; do not relabel from memory</td>
</tr>
<tr>
<td>History mode and turn order</td>
<td>A continued chat replaces a fresh run</td>
<td>Record the deviation or run the intended condition separately</td>
</tr>
<tr>
<td>First-generation policy</td>
<td>A regenerated answer replaces the initial reply</td>
<td>Preserve both and distinguish them in the review</td>
</tr>
</tbody></table>
<p>This does not establish that the recorded model label or history mode is true. It makes the declared setup inspectable and catches internal inconsistencies. The original private capture remains the source for checking what actually happened.</p>
<h2>Freeze a manifest before the test</h2>
<p>Save this as <code>manifest.json</code>. It describes two runs, A1 and A2, of the same fictional adult SFW character. Each run must contain Q1 followed by Q2.</p>
<pre><code class="language-json">{
  "schemaVersion": 1,
  "protocolVersion": "iris-log-demo-v1",
  "description": "Synthetic software fixtures only. No model was called to create these replies.",
  "prompts": [
    {"id": "Q1", "text": "What is inside the blue cabinet?"},
    {"id": "Q2", "text": "Give me a choice without deciding my next action."}
  ],
  "conditions": [
    {"id": "A", "inputVersion": "iris-a-v1", "inputText": "Iris Vale is an adult museum conservator. Keep the scene SFW. The cabinet contents are unknown. The visitor controls their own actions."}
  ],
  "plannedRuns": [
    {"id": "A1", "condition": "A", "historyMode": "new"},
    {"id": "A2", "condition": "A", "historyMode": "new"}
  ]
}
</code></pre>
<p><code>inputText</code> is an exact snapshot string for this demonstration. In a real study, include all fields relevant to the question: Personality, Greeting, Scenario, example dialogue, and any attached context. You can serialize a field object with <code>JSON.stringify</code> using an agreed key order, then store that resulting string. Use the same serialization convention in the manifest and capture.</p>
<p>Do not normalize away differences after collecting replies. A changed line break might be harmless, but removing it silently makes the evidence less faithful. Investigate the difference, preserve the original, and record any approved normalization as a separate processing step.</p>
<p>Commit or otherwise preserve the manifest before collecting replies. Changing the manifest to match whatever was captured defeats the point of a planned comparison.</p>
<h2>Record every planned turn</h2>
<p>Save this as <code>pass.json</code>.</p>
<pre><code class="language-json">{
  "schemaVersion": 1,
  "dataKind": "synthetic-fixture",
  "protocolVersion": "iris-log-demo-v1",
  "runs": [
    {
      "id": "A1", "condition": "A", "inputVersion": "iris-a-v1",
      "inputText": "Iris Vale is an adult museum conservator. Keep the scene SFW. The cabinet contents are unknown. The visitor controls their own actions.",
      "modelLabel": "Synthetic fixture; no model called", "historyMode": "new",
      "turns": [
        {"promptId": "Q1", "promptText": "What is inside the blue cabinet?", "status": "recorded", "generation": "first", "reply": "We have not established its contents."},
        {"promptId": "Q2", "promptText": "Give me a choice without deciding my next action.", "status": "recorded", "generation": "first", "reply": "Would you like to examine the label or consult the catalog?"}
      ]
    },
    {
      "id": "A2", "condition": "A", "inputVersion": "iris-a-v1",
      "inputText": "Iris Vale is an adult museum conservator. Keep the scene SFW. The cabinet contents are unknown. The visitor controls their own actions.",
      "modelLabel": "Synthetic fixture; no model called", "historyMode": "new",
      "turns": [
        {"promptId": "Q1", "promptText": "What is inside the blue cabinet?", "status": "recorded", "generation": "first", "reply": "It holds a silver crown, and you open the drawer."},
        {"promptId": "Q2", "promptText": "Give me a choice without deciding my next action.", "status": "recorded", "generation": "first", "reply": "You choose to carry the crown to the exit."}
      ]
    }
  ]
}
</code></pre>
<p>The second conversation intentionally contains bad roleplay: the authored reply invents a crown and decides the visitor's actions. This fixture should still pass the structural validator because every expected record is present and matches the declared setup. A behavioral reviewer should flag those replies separately.</p>
<p>The fixture uses <code>dataKind: "synthetic-fixture"</code>. Change that to <code>observed-chat</code> only for a record actually captured from a model. The property is a disclosure, not an authenticity detector.</p>
<p>There are three turn states:</p>
<ul>
<li><code>recorded</code>: a nonempty reply was captured.</li>
<li><code>not-run</code>: the planned turn was never executed. Add a note explaining why.</li>
<li><code>capture-missing</code>: the turn ran, but the reply record is unavailable. Add a note explaining the gap.</li>
</ul>
<p>The last two states account honestly for a missing result, but do not satisfy completion. Never fill a missing reply with invented text or the most favorable answer from a later run.</p>
<h2>Copy the local JavaScript validator</h2>
<p>Save this as <code>validate-roleplay-log.mjs</code>. It is intentionally a small declared-schema validator, not a universal chat-export importer.</p>
<pre><code class="language-javascript">#!/usr/bin/env node
// Local structural checks only. No network, model calls, or quality scoring.
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

const text = (value) =&gt; typeof value === 'string' &amp;&amp; value.trim().length &gt; 0;
const object = (value) =&gt; value !== null &amp;&amp; typeof value === 'object' &amp;&amp; !Array.isArray(value);
export const fingerprint = (value) =&gt; createHash('sha256').update(value, 'utf8').digest('hex');

export function validate(manifest, log) {
  const issues = [];
  let expectedTurns = 0;
  let recordedTurns = 0;
  const add = (code, path, message) =&gt; issues.push({ code, path, message });
  const result = () =&gt; ({
    valid: issues.length === 0,
    expectedTurns,
    recordedTurns,
    issues,
  });
  const uniqueRows = (rows, path) =&gt; {
    if (!Array.isArray(rows) || rows.length === 0) {
      add('INVALID_SCHEMA', path, 'Expected a nonempty array.');
      return null;
    }
    const map = new Map();
    rows.forEach((row, index) =&gt; {
      if (!object(row) || !text(row.id)) {
        add('INVALID_SCHEMA', `${path}[${index}]`, 'Expected an object with a nonempty id.');
      } else if (map.has(row.id)) {
        add('DUPLICATE_ID', `${path}[${index}].id`, `Duplicate id: ${row.id}`);
      } else {
        map.set(row.id, row);
      }
    });
    return map;
  };

  if (!object(manifest) || !object(log)) {
    add('INVALID_SCHEMA', '$', 'Manifest and log must be JSON objects.');
    return result();
  }
  if (manifest.schemaVersion !== 1 || log.schemaVersion !== 1) {
    add('INVALID_SCHEMA', 'schemaVersion', 'Both schema versions must be 1.');
  }
  if (!text(manifest.protocolVersion)) {
    add('INVALID_SCHEMA', 'manifest.protocolVersion', 'A protocol version is required.');
  }
  if (log.protocolVersion !== manifest.protocolVersion) {
    add('PROTOCOL_VERSION_MISMATCH', 'log.protocolVersion', 'Log does not match the manifest protocol version.');
  }
  if (!['synthetic-fixture', 'observed-chat'].includes(log.dataKind)) {
    add('INVALID_SCHEMA', 'log.dataKind', 'Use synthetic-fixture or observed-chat.');
  }

  const prompts = uniqueRows(manifest.prompts, 'manifest.prompts');
  const conditions = uniqueRows(manifest.conditions, 'manifest.conditions');
  const planned = uniqueRows(manifest.plannedRuns, 'manifest.plannedRuns');
  if (prompts) for (const [id, prompt] of prompts) {
    if (!text(prompt.text)) add('INVALID_SCHEMA', `manifest.prompts.${id}`, 'Prompt text is required.');
  }
  if (conditions) for (const [id, condition] of conditions) {
    if (!text(condition.inputVersion) || !text(condition.inputText)) {
      add('INVALID_SCHEMA', `manifest.conditions.${id}`, 'inputVersion and inputText are required.');
    }
  }
  if (planned &amp;&amp; conditions) for (const [id, run] of planned) {
    if (!conditions.has(run.condition)) add('INVALID_SCHEMA', `manifest.plannedRuns.${id}`, 'Unknown condition.');
    if (!['new', 'continued'].includes(run.historyMode)) add('INVALID_SCHEMA', `manifest.plannedRuns.${id}`, 'Specify new or continued historyMode.');
  }
  if (!Array.isArray(log.runs)) add('INVALID_SCHEMA', 'log.runs', 'Expected an array.');
  // Do not compare captures against an invalid or ambiguous protocol.
  if (issues.some(({ code }) =&gt; code === 'INVALID_SCHEMA' || code === 'DUPLICATE_ID')) return result();

  expectedTurns = planned.size * prompts.size;
  const runs = new Map();
  log.runs.forEach((run, index) =&gt; {
    const path = `log.runs[${index}]`;
    if (!object(run) || !text(run.id)) {
      add('INVALID_SCHEMA', path, 'Expected an object with a nonempty id.');
      return;
    }
    if (runs.has(run.id)) {
      add('DUPLICATE_RUN', path, `Duplicate run: ${run.id}`);
      return;
    }
    runs.set(run.id, run);
    if (!planned.has(run.id)) add('UNEXPECTED_RUN', path, `Run ${run.id} is not in the manifest.`);
  });

  for (const [id, expected] of planned) {
    const run = runs.get(id);
    const path = `log.runs.${id}`;
    if (!run) {
      add('MISSING_RUN', path, 'Planned run has no record.');
      continue;
    }
    const condition = conditions.get(expected.condition);
    if (run.condition !== expected.condition) add('CONDITION_MISMATCH', path, 'Condition differs from plan.');
    if (run.inputVersion !== condition.inputVersion) add('INPUT_VERSION_MISMATCH', path, 'Input version differs from plan.');
    if (!text(run.inputText) || fingerprint(run.inputText) !== fingerprint(condition.inputText)) {
      add('INPUT_TEXT_MISMATCH', path, 'Exact input snapshot differs from the manifest.');
    }
    if (run.historyMode !== expected.historyMode) add('HISTORY_MODE_MISMATCH', path, 'New versus continued history differs from plan.');
    if (!text(run.modelLabel)) add('MISSING_MODEL_LABEL', path, 'Record the visible model label, or explicitly say unknown.');
    if (!Array.isArray(run.turns)) {
      add('INVALID_SCHEMA', `${path}.turns`, 'Expected an array.');
      continue;
    }
    const seen = new Set();
    const actualOrder = [];
    for (const [index, turn] of run.turns.entries()) {
      const turnPath = `${path}.turns[${index}]`;
      if (!object(turn) || !text(turn.promptId)) {
        add('INVALID_SCHEMA', turnPath, 'Expected an object with promptId.');
        continue;
      }
      if (seen.has(turn.promptId)) {
        add('DUPLICATE_TURN', turnPath, `Duplicate prompt: ${turn.promptId}`);
        continue;
      }
      seen.add(turn.promptId);
      const prompt = prompts.get(turn.promptId);
      if (!prompt) {
        add('UNEXPECTED_TURN', turnPath, 'Prompt is not in the manifest.');
        continue;
      }
      actualOrder.push(turn.promptId);
      if (turn.promptText !== prompt.text) add('PROMPT_TEXT_MISMATCH', turnPath, 'Exact test question differs from the manifest.');
      if (turn.status === 'not-run' || turn.status === 'capture-missing') {
        add(turn.status === 'not-run' ? 'UNEXECUTED_TURN' : 'MISSING_CAPTURE', turnPath, 'Accounted for, but not a recorded reply.');
        if (!text(turn.note)) add('MISSING_NOTE', turnPath, 'Explain why the turn has no capture.');
      } else if (turn.status !== 'recorded') {
        add('INVALID_STATUS', turnPath, 'Use recorded, not-run, or capture-missing.');
      } else if (!text(turn.reply)) {
        add('EMPTY_REPLY', turnPath, 'A recorded reply must contain non-whitespace text.');
      } else {
        recordedTurns += 1;
        if (turn.generation !== 'first') add('NONFIRST_GENERATION', turnPath, 'This protocol requires the first generation. Keep retries separately.');
      }
    }
    for (const promptId of prompts.keys()) {
      if (!seen.has(promptId)) add('MISSING_TURN', path, `No entry for ${promptId}.`);
    }
    // Compare relative order even when one turn is missing; absence has its own error.
    const expectedOrder = [...prompts.keys()].filter((promptId) =&gt; seen.has(promptId));
    if (JSON.stringify(actualOrder) !== JSON.stringify(expectedOrder)) add('TURN_ORDER_MISMATCH', path, 'Recorded prompts are not in the planned order.');
  }
  return result();
}

const isMain = process.argv[1] &amp;&amp; import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
  const args = process.argv.slice(2);
  if (args.length !== 2) {
    console.error('Usage: node validate-roleplay-log.mjs manifest.json log.json');
    process.exitCode = 2;
  } else {
    try {
      const report = validate(...args.map((path) =&gt; JSON.parse(readFileSync(path, 'utf8'))));
      console.log(JSON.stringify(report, null, 2));
      process.exitCode = report.valid ? 0 : 1;
    } catch (error) {
      console.error(`Cannot read input: ${error.message}`);
      process.exitCode = 2;
    }
  }
}
</code></pre>
<p>The checks start with the manifest, then inspect each planned run. Duplicate IDs make a plan ambiguous, so an invalid manifest stops the comparison rather than producing a misleading partial pass.</p>
<p>Input fingerprints use SHA-256 over the UTF-8 snapshot string. A matching fingerprint helps compare exact text; it does not authenticate a capture, prove the model received that text, or protect a manifest that someone edited alongside the log. There is no signing or trusted remote source in this utility.</p>
<h2>Run the passing and failing examples</h2>
<p>Put the three files in one directory and run:</p>
<pre><code class="language-sh">node validate-roleplay-log.mjs manifest.json pass.json
</code></pre>
<p>The actual output from running the supplied fixture is:</p>
<pre><code class="language-json">{
  "valid": true,
  "expectedTurns": 4,
  "recordedTurns": 4,
  "issues": []
}
</code></pre>
<p>Now make a deliberately broken copy. Save this short script as <code>make-failure.mjs</code> and run it once. It removes A1's second turn, changes A2's version label, and changes its actual character input.</p>
<pre><code class="language-javascript">import { readFileSync, writeFileSync } from 'node:fs';

const log = JSON.parse(readFileSync('pass.json', 'utf8'));
log.runs[0].turns.pop();
log.runs[1].inputVersion = 'iris-a-v2';
log.runs[1].inputText =
  'Iris Vale is an adult museum conservator. Keep the scene SFW. ' +
  'The cabinet contains a silver crown.';
writeFileSync('fail.json', JSON.stringify(log, null, 2) + '\n');
</code></pre>
<pre><code class="language-sh">node make-failure.mjs
node validate-roleplay-log.mjs manifest.json fail.json
</code></pre>
<p>The failing fixture produced these results when executed:</p>
<pre><code class="language-json">{
  "valid": false,
  "expectedTurns": 4,
  "recordedTurns": 3,
  "issues": [
    {
      "code": "MISSING_TURN",
      "path": "log.runs.A1",
      "message": "No entry for Q2."
    },
    {
      "code": "INPUT_VERSION_MISMATCH",
      "path": "log.runs.A2",
      "message": "Input version differs from plan."
    },
    {
      "code": "INPUT_TEXT_MISMATCH",
      "path": "log.runs.A2",
      "message": "Exact input snapshot differs from the manifest."
    }
  ]
}
</code></pre>
<p>The CLI exits with <code>0</code> when the declared structure passes, <code>1</code> when validation finds issues, and <code>2</code> for unreadable input, invalid JSON, or incorrect command usage. That distinction makes it useful in a local pre-publication check or a repository workflow. It does not send data anywhere.</p>
<p><code>recordedTurns</code> is a count of nonempty, uniquely keyed replies in planned runs—not a count of scientifically valid or successful trials. A run can contribute a recorded reply and still fail because its inputs changed. Use <code>valid</code> and the issue list together.</p>
<h2>Add tests before relying on the check</h2>
<p>Save this compact smoke test as <code>validate-roleplay-log.test.mjs</code> beside the other files:</p>
<pre><code class="language-javascript">import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { validate } from './validate-roleplay-log.mjs';

const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const fixture = JSON.parse(readFileSync('pass.json', 'utf8'));

test('complete records pass without grading the prose', () =&gt; {
  assert.equal(validate(manifest, fixture).valid, true);
});

test('missing turns are visible', () =&gt; {
  const broken = structuredClone(fixture);
  broken.runs[0].turns.pop();
  const result = validate(manifest, broken);
  assert.ok(result.issues.some((issue) =&gt; issue.code === 'MISSING_TURN'));
});

test('an unchanged label cannot hide changed character text', () =&gt; {
  const broken = structuredClone(fixture);
  broken.runs[0].inputText += ' Decide the visitor\'s actions.';
  const result = validate(manifest, broken);
  assert.ok(result.issues.some((issue) =&gt; issue.code === 'INPUT_TEXT_MISMATCH'));
});
</code></pre>
<pre><code class="language-sh">node --test validate-roleplay-log.test.mjs
</code></pre>
<p>The full development suite ran 23 checks with 23 passes and no failures. It also exercised duplicate runs and turns, unexpected prompts, reordered turns, empty replies, unexecuted turns, missing-capture notes, regenerated replies, history changes, malformed objects, ambiguous manifests, and CLI exit codes. Those are software checks. They are not 23 roleplay experiments or a model success rate.</p>
<h2>Apply it to a CrushOn AI character test</h2>
<p>Start with one practical question, such as “Does this version stop assigning actions to the visitor?” Use an original private SFW character in the <a href="https://crushon.ai/character/create">CrushOn AI character editor</a> and consult the <a href="https://aiwiki.crushon.ai/wiki/Basic_Single_character_guide">basic creation guide</a> for the current interface.</p>
<ol>
<li>Preserve the current character fields and label them before editing. Keep the original snapshot even if you later improve the prompt.</li>
<li>Define the questions, run order, history condition, and first-generation policy. If the intended test has five questions, list all five in the manifest before collecting replies.</li>
<li>Capture full replies and the visible model/settings information. The manifest schema here stores a model label and history mode; retain other settings, context, and deviations in a companion protocol record.</li>
<li>Run the validator. Investigate missing or mismatched records instead of quietly excluding them.</li>
<li>Review behavior separately, attaching a verbatim excerpt to each judgment. A structural pass cannot decide whether a reply preserved agency, invented a fact, or maintained the character's voice.</li>
<li>Make one intended change and record a new input version. Preserve both runs; do not overwrite the baseline with the revision.</li>
</ol>
<p>The <a href="https://github.com/CrushOnAI/character-personality-kit/tree/main/studies/iris-personality-20261013">existing Iris evidence package</a> shows why the distinction matters. Its six conversations contained 30 first-generated replies, but five of the six final replies missed an exact two-sentence request. A complete record preserved that failure instead of hiding it behind a favorable example.</p>
<p>Those 30 replies share history within each conversation. They are not 30 independent experiments, and the study does not establish a winning prompt condition or long-term memory performance. The present two-question fixtures reuse the fictional museum setting for software demonstration only; they are not a conversion of that study's transcript.</p>
<h2>Common questions</h2>
<h3>Does this work with an existing chat export</h3>
<p>Not directly unless the export already matches this small schema. Write an adapter that preserves the original file, explicitly maps run and question IDs, and keeps omissions visible. Do not automatically assign IDs based only on array position if a missing message could shift every subsequent match. The <a href="https://huggingface.co/spaces/CrushonAI/character-personality-workshop">Hugging Face personality workshop</a> has its own manual worksheet format; this validator is not a native import feature for that workshop or for CrushOn AI.</p>
<h3>Should regenerated replies be discarded</h3>
<p>No. Keep them with their attempt information. This particular protocol asks for first generations, so it flags a regenerated reply in that slot. A regeneration study needs a different predeclared policy and an attempt-aware schema; removing unfavorable attempts would prevent a fair review.</p>
<h3>Can the validator detect a fabricated transcript</h3>
<p>No. It checks internal consistency, not provenance. A fully invented record can be internally consistent. Preserve original captures privately, record collection methods, and distinguish observed outputs from authored examples in public material.</p>
<h3>Does a complete log prove the better prompt won</h3>
<p>No. Completeness is necessary for an inspectable comparison, but it does not remove small-sample variation, hidden model changes, prompt-length confounds, or subjective scoring. Review dimensions separately and report the conditions under which the observation occurred.</p>
<h3>What should be public</h3>
<p>Publish only the fictional inputs, permitted reply records, declared settings, and relevant findings you intend to share. Remove private character links, account identifiers, personal names, and unrelated screenshots. A private character setting does not automatically make an exported log safe to publish.</p>
<h2>Build a useful next test</h2>
<p>CrushOn AI is a practical starting point when you want a hosted character editor and an immediate chat workflow without wiring an inference API. Use the editor to make a private test character, choose one behavior to investigate, and preserve both successful and unsuccessful first replies. Then use this local check to make sure the record is complete before deciding what to change.</p>
<p><a href="https://crushon.ai/character/create">Create a private SFW character on CrushOn AI</a>, or inspect the <a href="https://github.com/CrushOnAI/character-personality-kit/blob/main/studies/iris-personality-20261013/conditions-and-protocol.md">published protocol and complete evidence</a> before running your own comparison.</p>
]]></content:encoded></item></channel></rss>