Backend: - provider: fall back to parsing a plain JSON body when a server ignores stream=true (was: silent empty turn); error if response has no text (#5) - memorybank: clamp cursors after undo/retry shrinks the action list, and translate summary_cursor (list position) to an Action.index boundary before comparing with Memory.source_end (#7) - memorybank: pinned memories now count toward the top_k budget (#8) - memorybank: cosine() returns 0.0 on dimension mismatch; changing the embedding model clears stored vectors so they re-embed (#9) - scripting: MAX_STORY_CARDS cap now counts cards inserted during the hook, so a script can't add unbounded cards in one turn (#10) - settings: /test tolerates non-dict JSON from /models (#13) - scenarios: import accepts worldInformation as a story-card source (#14) Frontend: - per-key debounce timers in PlotPanel and ScenarioEditor — editing two things within 600ms no longer drops the first save (#15, #16) - Continue button no longer discards typed input (#17) - failed retry resyncs actions from the server instead of leaving the removed action missing (#18) - Settings save/test surface errors instead of hanging on Testing… (#19) - InsightsPanel ignores stale responses from superseded requests (#20) - placeholder scan includes story-card trigger keys (#21) addStoryCard returning the 0-based index (falsy for the first card) matches real AI Dungeon per the scripting guidebook — kept, documented (#11). Statuses updated in CODE_REVIEW_FINDINGS.md; stale entries for previously fixed items (#1-4, #6, #12) corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""AI Dungeon-compatible script execution in an embedded QuickJS sandbox.
|
|
|
|
Each hook run is fully isolated (fresh Context), capped at 16 MB memory and
|
|
2 seconds CPU, with no filesystem/network/process access (QuickJS has none by
|
|
default). Scripts follow the AI Dungeon contract: define a `modifier(text)`
|
|
and call it as the last line; its return value `{ text, stop }` is the result.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
|
|
import quickjs
|
|
|
|
MEMORY_LIMIT = 16 * 1024 * 1024
|
|
TIME_LIMIT_SECONDS = 2
|
|
HISTORY_WINDOW = 100 # recent actions exposed as `history`
|
|
|
|
# Globals per the official docs: text, state, history, storyCards, info,
|
|
# log/console.log, story card functions, plus legacy worldInfo aliases.
|
|
PRELUDE = """
|
|
"use strict";
|
|
var __logs = [];
|
|
var state = __DATA__.state;
|
|
var text = __DATA__.text;
|
|
var history = __DATA__.history;
|
|
var storyCards = __DATA__.storyCards;
|
|
var info = __DATA__.info;
|
|
|
|
function log(msg) {
|
|
__logs.push(typeof msg === "string" ? msg : JSON.stringify(msg));
|
|
}
|
|
var console = { log: log };
|
|
|
|
// Returns the new card's index, or false if a card with those keys exists —
|
|
// matching real AI Dungeon. Note index 0 is falsy; that quirk is upstream's.
|
|
function addStoryCard(keys, entry, type) {
|
|
for (var i = 0; i < storyCards.length; i++) {
|
|
if (storyCards[i].keys === keys) return false;
|
|
}
|
|
storyCards.push({ id: null, keys: keys || "", entry: entry || "", type: type || "" });
|
|
return storyCards.length - 1;
|
|
}
|
|
function updateStoryCard(index, keys, entry, type) {
|
|
var card = storyCards[index];
|
|
if (!card) throw new Error("Story card not found");
|
|
card.keys = keys;
|
|
card.entry = entry;
|
|
card.type = type;
|
|
}
|
|
function removeStoryCard(index) {
|
|
if (!storyCards[index]) throw new Error("Story card not found");
|
|
storyCards.splice(index, 1);
|
|
}
|
|
|
|
// Legacy aliases used by older AI Dungeon scripts.
|
|
var worldInfo = storyCards;
|
|
var worldEntries = storyCards;
|
|
function addWorldEntry(keys, entry) { return addStoryCard(keys, entry, ""); }
|
|
function updateWorldEntry(index, keys, entry) {
|
|
var card = storyCards[index];
|
|
if (!card) throw new Error("World entry not found");
|
|
card.keys = keys;
|
|
card.entry = entry;
|
|
}
|
|
function removeWorldEntry(index) { return removeStoryCard(index); }
|
|
"""
|
|
|
|
COLLECT = """
|
|
JSON.stringify({
|
|
result: (typeof __result === "undefined" || __result === null) ? null : __result,
|
|
state: state,
|
|
storyCards: storyCards,
|
|
logs: __logs
|
|
})
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class HookResult:
|
|
text: str
|
|
stop: bool = False
|
|
state: dict = field(default_factory=dict)
|
|
story_cards: list = field(default_factory=list)
|
|
logs: list = field(default_factory=list)
|
|
error: str | None = None
|
|
|
|
|
|
def run_hook(
|
|
library_js: str,
|
|
hook_js: str,
|
|
text: str,
|
|
state: dict,
|
|
history: list[dict],
|
|
story_cards: list[dict],
|
|
info: dict,
|
|
) -> HookResult:
|
|
"""Run one modifier hook. Never raises: failures come back as .error with
|
|
text/state/cards unchanged, so a bad script can't break a turn."""
|
|
unchanged = HookResult(text=text, state=state, story_cards=story_cards)
|
|
source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js
|
|
if not source.strip():
|
|
return unchanged
|
|
|
|
data = {
|
|
"state": state,
|
|
"text": text,
|
|
"history": history[-HISTORY_WINDOW:],
|
|
"storyCards": story_cards,
|
|
"info": info,
|
|
}
|
|
try:
|
|
ctx = quickjs.Context()
|
|
ctx.set_memory_limit(MEMORY_LIMIT)
|
|
ctx.set_time_limit(TIME_LIMIT_SECONDS)
|
|
ctx.eval(f"var __DATA__ = {json.dumps(data)};")
|
|
ctx.eval(PRELUDE)
|
|
ctx.eval(f"var __SRC__ = {json.dumps(source)};")
|
|
# Indirect eval keeps the script in global scope, so `modifier(text)` as the
|
|
# script's final expression statement becomes the completion value.
|
|
ctx.eval("var __result = (0, eval)(__SRC__);")
|
|
collected = json.loads(ctx.eval(COLLECT))
|
|
except quickjs.JSException as exc:
|
|
unchanged.error = f"Script error: {exc}"
|
|
return unchanged
|
|
except Exception as exc: # memory limit, invalid JSON state, engine faults
|
|
unchanged.error = f"Script execution failed: {exc}"
|
|
return unchanged
|
|
|
|
result = collected.get("result")
|
|
new_text, stop = text, False
|
|
if isinstance(result, dict):
|
|
if isinstance(result.get("text"), str):
|
|
new_text = result["text"]
|
|
stop = bool(result.get("stop"))
|
|
elif isinstance(result, str):
|
|
new_text = result
|
|
|
|
new_state = collected.get("state")
|
|
return HookResult(
|
|
text=new_text,
|
|
stop=stop,
|
|
state=new_state if isinstance(new_state, dict) else {},
|
|
story_cards=collected.get("storyCards") or [],
|
|
logs=collected.get("logs") or [],
|
|
)
|