Add brief editing and character record updates

This commit is contained in:
Joey Grasty
2026-08-23 21:01:16 -05:00
parent dfbd313870
commit 4499d4bd8f
70 changed files with 13946 additions and 152 deletions

View File

@@ -81,6 +81,7 @@ def text_to_rtf(value: str) -> str:
class ScrivenerReader:
def __init__(self, project: Path):
self.project = project
self.bindings_path = project / "Files" / "authoring-system-bindings.json"
project_files = list(project.glob("*.scrivx"))
if len(project_files) != 1:
raise ValueError("Project must contain exactly one .scrivx file.")
@@ -88,6 +89,16 @@ class ScrivenerReader:
self.root_items = self._load_binder()
self.by_uuid = {item.uuid: item for item in self.walk()}
def bindings(self) -> dict[str, str]:
if not self.bindings_path.exists():
return {}
try:
data = json.loads(self.bindings_path.read_text(encoding="utf-8"))
bindings = data.get("scene_to_brief", {})
return {str(scene_uuid): str(brief_uuid) for scene_uuid, brief_uuid in bindings.items()} if isinstance(bindings, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _load_binder(self) -> list[Item]:
root = ET.parse(self.project_file).getroot()
binder = root.find("Binder")
@@ -172,6 +183,8 @@ class ScrivenerReader:
manuscript = self.manuscript_scenes()
briefs = self.records(briefs_folder, self)
brief_by_title = {brief["title"]: brief for brief in briefs}
brief_by_uuid = {brief["uuid"]: brief for brief in briefs}
bindings = self.bindings()
summary_by_title = {record["title"]: record for record in self.records(summaries_folder, self)}
character_by_title = {character["title"]: character["uuid"] for character in self.records(self.top_level("Characters"), self)}
package_context: dict[str, dict] = {}
@@ -190,18 +203,26 @@ class ScrivenerReader:
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
paired = []
bound_brief_uuids: set[str] = set()
for scene in manuscript:
brief = brief_by_uuid.get(bindings.get(scene["uuid"], ""))
binding_state = "stable" if brief else ""
if brief is None:
brief = brief_by_title.get(scene["title"])
binding_state = "legacy-title-match" if brief else "unpaired"
if brief:
bound_brief_uuids.add(brief["uuid"])
paired.append(
{
**scene,
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
"brief": (brief or {}).get("text", ""),
"brief_uuid": (brief or {}).get("uuid"),
"binding_state": binding_state,
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
}
)
manuscript_titles = {scene["title"] for scene in manuscript}
return {
"project": str(self.project),
"locked": (self.project / "Files" / "user.lock").exists(),
@@ -212,8 +233,13 @@ class ScrivenerReader:
"scenes": paired,
"characters": self.records(self.top_level("Characters"), self),
"places": self.records(self.top_level("Places"), self),
"unmatched_briefs": [brief for brief in briefs if brief["title"] not in manuscript_titles],
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
"unmatched_briefs": [brief for brief in briefs if brief["uuid"] not in bound_brief_uuids],
"unmatched_scenes": [scene for scene in paired if not scene["brief_uuid"]],
"binding_counts": {
"stable": sum(scene["binding_state"] == "stable" for scene in paired),
"legacy": sum(scene["binding_state"] == "legacy-title-match" for scene in paired),
"unpaired": sum(scene["binding_state"] == "unpaired" for scene in paired),
},
}
@staticmethod
@@ -233,6 +259,30 @@ class ScrivenerWriter:
if (self.project / "Files" / "user.lock").exists():
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
def _write_bindings(self, bindings: dict[str, str]) -> None:
payload = {
"version": 1,
"updated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"scene_to_brief": bindings,
}
temporary = self.reader.bindings_path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(temporary, self.reader.bindings_path)
def reconcile_scene_bindings(self) -> dict:
"""Persist currently resolved scene/BRIEF pairs without relying on their titles."""
self._assert_unlocked()
data = self.reader.summary()
briefs_folder = self.reader.child(self.reader.top_level("Authoring System"), "Scene Briefs")
valid_brief_uuids = {item.uuid for item in (briefs_folder.children if briefs_folder else []) if item.item_type == "Text"}
bindings = {scene_uuid: brief_uuid for scene_uuid, brief_uuid in self.reader.bindings().items() if scene_uuid in self.reader.by_uuid and brief_uuid in valid_brief_uuids}
for scene in data["scenes"]:
if scene["brief_uuid"]:
bindings[scene["uuid"]] = scene["brief_uuid"]
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return self.reader.summary()["binding_counts"]
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
tree = ET.parse(self.reader.project_file)
binder = tree.getroot().find("Binder")
@@ -311,6 +361,35 @@ class ScrivenerWriter:
def _save_tree(self, tree: ET.ElementTree) -> None:
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
def save_brief(self, scene_uuid: str, text: str) -> dict:
"""Update an existing BRIEF or create and bind one for a planned scene."""
self._assert_unlocked()
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected Manuscript scene no longer exists.")
paired = next((item for item in self.reader.summary()["scenes"] if item["uuid"] == scene_uuid), None)
if paired and paired["brief_uuid"]:
brief_uuid = paired["brief_uuid"]
path = self.project / "Files" / "Data" / brief_uuid / "content.rtf"
content = text_to_rtf(text).encode("utf-8")
path.write_bytes(content)
self._update_checksum(brief_uuid, content)
bindings = self.reader.bindings()
bindings[scene_uuid] = brief_uuid
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return {"created": False, "brief_uuid": brief_uuid}
self._backup_binder()
tree, binder = self._tree_and_binder()
briefs_folder = self._folder(binder, ["Authoring System", "Scene Briefs"])
brief_uuid = self._add_text(briefs_folder, scene.title, text)
self._save_tree(tree)
bindings = self.reader.bindings()
bindings[scene_uuid] = brief_uuid
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return {"created": True, "brief_uuid": brief_uuid}
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str, character_uuids: list[str]) -> dict:
self._assert_unlocked()
if not draft_text.strip() or not package.strip():
@@ -392,12 +471,23 @@ class ScrivenerWriter:
self._update_checksum(item.uuid, content)
return True
def save_continuity_updates(self, scene_uuid: str, character_updates: list[dict], lexicon_additions: str, world_note_additions: str) -> dict:
def _replace_item(self, item: Item, text: str) -> bool:
text = text.strip()
if not text:
raise ValueError("A Character record cannot be replaced with empty text.")
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
if self.reader.content(item).strip() == text:
return False
content = text_to_rtf(text + "\n").encode("utf-8")
path.write_bytes(content)
self._update_checksum(item.uuid, content)
return True
def save_continuity_updates(self, scene_uuid: str, character_updates: list[dict], new_characters: list[dict], lexicon_additions: str, world_note_additions: str) -> dict:
self._assert_unlocked()
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected scene no longer exists.")
stamp = f"Continuity update from {scene.title}:"
characters_folder = self.reader.top_level("Characters")
characters = {item.uuid: item for item in (characters_folder.children if characters_folder else []) if item.item_type == "Text"}
saved_characters = []
@@ -405,15 +495,34 @@ class ScrivenerWriter:
item = characters.get(update.get("uuid", ""))
if item is None:
raise ValueError("A proposed character update does not target a current Character record.")
if self._append_to_item(item, f"{stamp}\n{update.get('text', '').strip()}"):
if self._replace_item(item, update.get("text", "")):
saved_characters.append(item.title)
stamp = f"Continuity update from {scene.title}:"
authoring = self.reader.top_level("Authoring System")
continuity = self.reader.child(authoring, "Continuity")
lexicon = self.reader.child(continuity, "Master Lexicon")
world_notes = self.reader.child(continuity, "World Notes")
lexicon_saved = self._append_to_item(lexicon, f"{stamp}\n{lexicon_additions.strip()}") if lexicon and lexicon_additions.strip() else False
world_saved = self._append_to_item(world_notes, f"{stamp}\n{world_note_additions.strip()}") if world_notes and world_note_additions.strip() else False
return {"characters": saved_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
existing_titles = {item.title.casefold() for item in characters.values()}
created_characters = []
candidates = []
for character in new_characters:
name = str(character.get("name", "")).strip()
description = str(character.get("text", "")).strip()
if name and description and name.casefold() not in existing_titles:
candidates.append((name, description))
existing_titles.add(name.casefold())
if candidates:
self._backup_binder()
tree, binder = self._tree_and_binder()
characters_folder = self._folder(binder, ["Characters"])
for name, description in candidates:
self._add_text(characters_folder, name, description)
created_characters.append(name)
self._save_tree(tree)
self.reader = ScrivenerReader(self.project)
return {"characters": saved_characters, "new_characters": created_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
def section(name: str, value: str) -> str:
@@ -535,7 +644,7 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
raise ValueError("A canonical scene is required to propose continuity updates.")
data = project.summary()
context = {"characters": data["characters"], "master_lexicon": data["master_lexicon"], "world_notes": data["world_notes"]}
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character; do not create characters here. Use empty arrays or empty strings when there is no justified addition."
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), new_characters (array of objects with name and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character. Put a newly introduced named person in new_characters, never in lexicon_additions; name is the character's display name and text is an initial concise factual Character record. Do not create a new character for a passing unnamed person. Use empty arrays or empty strings when there is no justified addition."
user = "Existing continuity records:\n" + json.dumps(context, ensure_ascii=False) + f"\n\nCanonical scene ({scene.title}):\n{text}"
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 1200}).encode()
request = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, method="POST")
@@ -548,16 +657,35 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
if not isinstance(raw, str):
raise ValueError("OpenRouter response did not contain continuity proposals.")
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.IGNORECASE)
# Frontier models occasionally add a one-line introduction despite the JSON-only
# instruction. Accept one complete object embedded in that formatting, but do
# not attempt to repair malformed JSON.
first_brace = raw.find("{")
last_brace = raw.rfind("}")
if first_brace >= 0 and last_brace > first_brace:
raw = raw[first_brace:last_brace + 1]
try:
proposals = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
if not isinstance(proposals, dict):
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.")
valid_uuids = {character["uuid"] for character in data["characters"]}
updates = [
{"uuid": update.get("character_uuid", ""), "text": str(update.get("text", "")).strip()}
for update in proposals.get("character_updates", []) if isinstance(update, dict) and update.get("character_uuid") in valid_uuids and str(update.get("text", "")).strip()
]
return {"character_updates": updates, "lexicon_additions": str(proposals.get("lexicon_additions", "")).strip(), "world_note_additions": str(proposals.get("world_note_additions", "")).strip(), "model": result.get("model", OPENROUTER_MODEL)}
existing_names = {character["title"].casefold() for character in data["characters"]}
new_characters = []
for character in proposals.get("new_characters", []):
if not isinstance(character, dict):
continue
name = str(character.get("name", "")).strip()
text = str(character.get("text", "")).strip()
if name and text and name.casefold() not in existing_names:
new_characters.append({"name": name, "text": text})
existing_names.add(name.casefold())
return {"character_updates": updates, "new_characters": new_characters, "lexicon_additions": str(proposals.get("lexicon_additions", "")).strip(), "world_note_additions": str(proposals.get("world_note_additions", "")).strip(), "model": result.get("model", OPENROUTER_MODEL)}
class AppHandler(SimpleHTTPRequestHandler):
@@ -593,7 +721,7 @@ class AppHandler(SimpleHTTPRequestHandler):
def do_POST(self) -> None:
path = urlparse(self.path).path
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates"}:
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates", "/api/reconcile-bindings", "/api/save-brief"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
@@ -635,9 +763,19 @@ class AppHandler(SimpleHTTPRequestHandler):
if not payload.get("updates_confirmation"):
raise ValueError("Confirm the proposed continuity updates before saving them to Scrivener.")
writer = ScrivenerWriter(self.reader.project)
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("new_characters", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
AppHandler.reader = ScrivenerReader(self.reader.project)
self.send_json(result)
elif path == "/api/reconcile-bindings":
writer = ScrivenerWriter(self.reader.project)
counts = writer.reconcile_scene_bindings()
AppHandler.reader = writer.reader
self.send_json({"binding_counts": counts})
elif path == "/api/save-brief":
writer = ScrivenerWriter(self.reader.project)
result = writer.save_brief(payload.get("scene_uuid", ""), payload.get("text", ""))
AppHandler.reader = writer.reader
self.send_json(result)
else:
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
except (ValueError, json.JSONDecodeError) as error:

View File

@@ -43,7 +43,7 @@ function paragraphTail(text, targetWords) {
function loadStory() {
const selectedSummaries = [...byId('story-summaries').querySelectorAll('input:checked')]
.map((input) => sceneByUuid(input.value)).filter(Boolean);
byId('story').value = selectedSummaries.map((scene) => `${scene.title}\n${scene.summary}`).join('\n\n');
byId('story').value = selectedSummaries.map((scene) => scene.summary).join('\n\n');
byId('new-thread').checked = selectedSummaries.length === 0;
}
@@ -85,9 +85,7 @@ function addRecall() {
if (!source) return;
const value = byId('recall-mode').value === 'full' ? source.text : source.summary;
if (!value) { byId('error').textContent = `${source.title} has no approved dense summary yet; choose Full canonical scene instead.`; return; }
const form = byId('recall-mode').value === 'full' ? 'full canonical scene' : 'approved dense summary';
const block = `${source.title} (${form})\n${value}`;
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${block}` : block;
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${value}` : value;
}
function populateContinuity(defaults = false) {
@@ -117,8 +115,12 @@ function escapeHtml(value) {
function updateSceneState() {
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${scene.brief ? 'BRIEF paired' : 'BRIEF missing'}` : '';
byId('brief').textContent = scene && scene.brief ? scene.brief : 'No paired BRIEF exists for this scene.';
const binding = scene?.binding_state === 'stable' ? 'stable UUID binding' : scene?.binding_state === 'legacy-title-match' ? 'legacy title match' : 'BRIEF missing';
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${binding}` : '';
byId('brief').value = scene?.brief || '';
byId('brief').disabled = project.locked;
byId('save-brief').disabled = project.locked || !scene;
byId('brief-status').textContent = project.locked ? 'Close Scrivener before saving a BRIEF.' : scene?.brief_uuid ? 'Editing the paired BRIEF.' : 'Saving will create and stably bind a new BRIEF for this scene.';
byId('propose-summary').disabled = !(scene && scene.state === 'canonical');
byId('propose-updates').disabled = !(scene && scene.state === 'canonical');
}
@@ -146,8 +148,10 @@ function render(data) {
byId('status').className = `status ${data.locked ? 'warning' : 'ok'}`;
byId('project-check').innerHTML = [
['Scenes', data.scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
['Scenes without brief', data.unmatched_scenes.length], ['Characters', data.characters.length], ['Places', data.places.length],
['Scenes without brief', data.unmatched_scenes.length], ['Stable scene bindings', data.binding_counts.stable], ['Legacy title matches', data.binding_counts.legacy], ['Characters', data.characters.length], ['Places', data.places.length],
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
byId('reconcile-bindings').disabled = data.locked;
byId('binding-status').textContent = data.locked ? 'Close Scrivener before creating or updating bindings.' : data.binding_counts.legacy ? 'Create stable bindings before renaming or moving scenes.' : 'Scene/BRIEF bindings are stored by UUID.';
byId('scene').innerHTML = data.scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)}${escapeHtml(scene.chapter)}</option>`).join('');
if ([...byId('scene').options].some((option) => option.value === priorSceneUuid)) byId('scene').value = priorSceneUuid;
checks(byId('characters'), data.characters);
@@ -175,6 +179,24 @@ async function request(path, body) {
return data;
}
async function reconcileBindings() {
byId('error').textContent = '';
try {
const data = await request('/api/reconcile-bindings', {});
byId('binding-status').textContent = `${data.binding_counts.stable} stable binding${data.binding_counts.stable === 1 ? '' : 's'} saved.`;
await load();
} catch (error) { byId('error').textContent = error.message; }
}
async function saveBrief() {
byId('error').textContent = '';
try {
const data = await request('/api/save-brief', { scene_uuid: byId('scene').value, text: byId('brief').value });
byId('brief-status').textContent = data.created ? 'New paired BRIEF created and bound by UUID.' : 'BRIEF saved.';
await load();
} catch (error) { byId('error').textContent = error.message; }
}
async function build() {
byId('error').textContent = ''; byId('warnings').textContent = '';
try {
@@ -266,6 +288,7 @@ async function saveSummary() {
function resetUpdates() {
updatesSceneUuid = '';
byId('character-updates').innerHTML = '';
byId('new-character-updates').innerHTML = '';
byId('lexicon-additions').value = ''; byId('lexicon-additions').disabled = true;
byId('world-note-additions').value = ''; byId('world-note-additions').disabled = true;
byId('updates-confirmation').checked = false; byId('save-updates').disabled = true;
@@ -275,10 +298,20 @@ function resetUpdates() {
function renderCharacterUpdates(updates) {
byId('character-updates').innerHTML = updates.map((update) => {
const character = project.characters.find((candidate) => candidate.uuid === update.uuid);
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')}</span><textarea rows="3">${escapeHtml(update.text)}</textarea></label>`;
const updateHeading = `Continuity update from ${sceneByUuid(updatesSceneUuid).title}:`;
const completeRecord = `${character ? character.text.trim() : ''}\n\n${updateHeading}\n${update.text}`.trim();
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')} — complete editable record</span><textarea rows="8">${escapeHtml(completeRecord)}</textarea></label>`;
}).join('') || '<p class="muted">No character updates proposed.</p>';
}
function renderNewCharacterUpdates(characters) {
byId('new-character-updates').innerHTML = characters.map((character, index) => `
<label class="proposal"><input type="checkbox" data-new-character="${index}" checked> <span>New Character</span>
<input class="new-character-name" value="${escapeHtml(character.name)}" aria-label="New character name">
<textarea class="new-character-text" rows="3">${escapeHtml(character.text)}</textarea>
</label>`).join('') || '<p class="muted">No new Characters proposed.</p>';
}
async function proposeUpdates() {
byId('error').textContent = '';
const scene = sceneByUuid(byId('scene').value);
@@ -286,27 +319,31 @@ async function proposeUpdates() {
const data = await request('/api/propose-continuity-updates', { scene_uuid: scene.uuid, text: scene.text, send_confirmation: byId('updates-send-confirmation').checked });
updatesSceneUuid = scene.uuid;
renderCharacterUpdates(data.character_updates || []);
renderNewCharacterUpdates(data.new_characters || []);
byId('lexicon-additions').value = data.lexicon_additions || '';
byId('lexicon-additions').disabled = false;
byId('world-note-additions').value = data.world_note_additions || '';
byId('world-note-additions').disabled = false;
byId('save-updates').disabled = false;
byId('updates-status').textContent = 'Review each proposal. Uncheck a character update to exclude it.';
byId('updates-status').textContent = 'Each selected Character field is a complete replacement record: trim or revise it before saving. Uncheck to exclude it.';
} catch (error) { byId('error').textContent = error.message; }
}
async function saveUpdates() {
byId('error').textContent = '';
const characterUpdates = [...byId('character-updates').querySelectorAll('input:checked')].map((input) => ({ uuid: input.dataset.uuid, text: input.parentElement.querySelector('textarea').value }));
const newCharacters = [...byId('new-character-updates').querySelectorAll('input:checked')].map((input) => ({ name: input.parentElement.querySelector('.new-character-name').value, text: input.parentElement.querySelector('.new-character-text').value }));
try {
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, lexicon_additions: byId('lexicon-additions').value, world_note_additions: byId('world-note-additions').value, updates_confirmation: byId('updates-confirmation').checked });
const saved = [...data.characters, data.lexicon ? 'Master Lexicon' : '', data.world_notes ? 'World Notes' : ''].filter(Boolean);
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, new_characters: newCharacters, lexicon_additions: byId('lexicon-additions').value, world_note_additions: byId('world-note-additions').value, updates_confirmation: byId('updates-confirmation').checked });
const saved = [...data.characters, ...(data.new_characters || []).map((name) => `new Character: ${name}`), data.lexicon ? 'Master Lexicon' : '', data.world_notes ? 'World Notes' : ''].filter(Boolean);
byId('updates-status').textContent = saved.length ? `Saved updates to: ${saved.join(', ')}.` : 'No new continuity additions were saved.';
await load();
} catch (error) { byId('error').textContent = error.message; }
}
byId('scene').addEventListener('change', changeScene);
byId('reconcile-bindings').addEventListener('click', reconcileBindings);
byId('save-brief').addEventListener('click', saveBrief);
byId('load-story').addEventListener('click', loadStory);
byId('load-previous').addEventListener('click', loadPrevious);
byId('add-recall').addEventListener('click', addRecall);

View File

@@ -18,6 +18,8 @@
<aside>
<h2>Project check</h2>
<dl id="project-check"></dl>
<button id="reconcile-bindings" type="button">Create/update stable scene bindings</button>
<p id="binding-status" class="muted"></p>
<h2>Scene</h2>
<label for="scene">Paired Manuscript scene</label>
<select id="scene"></select>
@@ -29,8 +31,10 @@
</aside>
<section class="workspace">
<section class="brief-panel">
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
<h2>Author BRIEF <span class="read-only">editable</span></h2>
<textarea id="brief" rows="10" placeholder="Write the BRIEF that drives this scene."></textarea>
<button id="save-brief" type="button">Save BRIEF</button>
<p id="brief-status" class="muted"></p>
</section>
<section class="brief-panel">
<h2>World Notes <span class="read-only">read-only reference</span></h2>
@@ -124,6 +128,7 @@
<label class="checkbox"><input id="updates-send-confirmation" type="checkbox"> I understand this sends the canonical scene and current continuity records to OpenRouter.</label>
<button id="propose-updates" disabled>Propose continuity updates</button>
<div id="character-updates" class="proposal-list"></div>
<div id="new-character-updates" class="proposal-list"></div>
<label>LEXICON additions <span class="read-only">editable</span>
<textarea id="lexicon-additions" rows="4" disabled></textarea>
</label>

View File

@@ -11,9 +11,9 @@ button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; ba
button:disabled { cursor: not-allowed; opacity: .5; } .draft-actions, .final-actions { margin-top: .5rem; padding: .75rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .draft-actions button, .final-actions button { margin: .6rem .5rem 0 0; }
.continuity-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #eef4f0; } .continuity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } .continuity-panel button { margin: .6rem 0 0; } .carry-actions { margin-top: 1rem; padding-top: .25rem; border-top: 1px solid #cbd8d0; } .carry-actions button { margin-right: .5rem; } .context-list { min-height: 6rem; max-height: 12rem; overflow: auto; margin-top: .35rem; padding: .45rem; border: 1px solid #bab2a5; background: #fffefb; } .context-list label { padding: .22rem 0; font-weight: 400; }
#working-draft { min-height: 28rem; max-height: 65vh; resize: vertical; background: #fffefb; color: #17212b; font: .9rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea { margin-top: .55rem; font-weight: 400; }
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea, .proposal input:not([type]) { margin-top: .55rem; font-weight: 400; }
pre { min-height: 28rem; max-height: 65vh; overflow: auto; margin: 0; padding: 1rem; border: 1px solid #d8d2c5; background: #202525; color: #e5f3ed; white-space: pre-wrap; font: .82rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .brief-panel textarea { min-height: 12rem; font: .95rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
dl { display: grid; grid-template-columns: 1fr auto; margin: 0; font-size: .9rem; } dt, dd { margin: 0; padding: .25rem 0; border-bottom: 1px solid #ece6da; } dd { font-weight: 700; } .status { border-radius: 999px; padding: .45rem .75rem; font-size: .78rem; } .ok { background: #d9eee4; color: #164d38; } .warning { background: #ffe2d6; color: #7f2c19; } .muted { color: #665f54; font-size: .8rem; } .error { color: #aa2717; font-weight: 700; } .checkbox { margin-top: 1rem; font-weight: 400; }
.warnings { color: #805215; font-weight: 650; }
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid, .continuity-grid { grid-template-columns: 1fr; } }