Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fbeb016
bd: remove parents from ge-hch.5.16.1 (make top-level)
SorraTheOrc Jan 19, 2026
5ec389e
ge-hch.5.16: add runtime hook manager, integration state machine, che…
SorraTheOrc Jan 19, 2026
28d710a
ge-hch.5.16.10: add telemetry & persistence subscribers, README and t…
SorraTheOrc Jan 19, 2026
f261470
ge-hch.5.16.11: add save/load adapters with checksum and migration ho…
SorraTheOrc Jan 19, 2026
68f156b
ge-hch.5.16.12: add simple checkpoint fuzz harness and Playwright E2E…
SorraTheOrc Jan 19, 2026
5c9d9ba
chore(demo): add browser RuntimeHooks shim and instrument inkrunner w…
SorraTheOrc Jan 19, 2026
e3a1256
chore(build): generate bundled HookManager for demo and prefer it ove…
SorraTheOrc Jan 19, 2026
3dfbc06
chore(demo): log which RuntimeHooks loader initialized (shim fallback)
SorraTheOrc Jan 19, 2026
8f2a7ec
chore(demo): clearer console feedback for bundled/shim HookManager lo…
SorraTheOrc Jan 19, 2026
a5b1f18
fix(demo): restore story HTML on load if provided, otherwise rebuild …
SorraTheOrc Jan 19, 2026
6771b29
fix(demo): reconstruct visible story text from InkJS saved outputStre…
SorraTheOrc Jan 19, 2026
b5b8e67
fix(demo): include renderedHtml in save payload to ensure determinist…
SorraTheOrc Jan 19, 2026
3e02331
test(e2e): assert visible story snapshot preserved across save/load
SorraTheOrc Jan 19, 2026
37bd6e6
feat(demo): add demo persistence subscriber & .saves writer; wire up …
SorraTheOrc Jan 19, 2026
56b2305
chore(demo): register demo persistence subscriber in runtime shim; sh…
SorraTheOrc Jan 19, 2026
c995b89
test(demo): add unit tests for demo persistence subscriber (post_chec…
SorraTheOrc Jan 19, 2026
b13ea60
docs(hook-manager): document demo persistence and rollback toast; add…
SorraTheOrc Jan 19, 2026
55a3468
docs(runtime): add runtime-hooks usage guide and rollback runbook
SorraTheOrc Jan 19, 2026
932c46b
refactor(director): externalize tuning to src/runtime/director-config…
SorraTheOrc Jan 19, 2026
7f19d9c
chore: expose directorConfig defaults in .gengine example and local c…
SorraTheOrc Jan 19, 2026
c1aafd9
chore: remove test artifact from src/.saves
SorraTheOrc Jan 19, 2026
b7f0245
chore: add demo save artifacts
SorraTheOrc Jan 19, 2026
ea6a08a
docs: document directorConfig and verify steps
SorraTheOrc Jan 19, 2026
446cd1d
chore: expose director riskThreshold in .gengine configs
SorraTheOrc Jan 19, 2026
3620bad
chore: add riskThreshold default to director-config and use in browser
SorraTheOrc Jan 19, 2026
66063b6
demo: load runtime Director defaults into browser; generate director-…
SorraTheOrc Jan 19, 2026
399749a
chore: ignore local editor/runtime saves (src/.saves) and remove them…
SorraTheOrc Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions .gengine/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ GENGINE_CORS_PROXY_PORT: 8010
# Verbose logging for the proxy (true/false)
GENGINE_CORS_PROXY_VERBOSE: false

# Director tuning defaults
# You can override these by adding a `directorConfig:` mapping here.
# Supported keys: weights, pacingTargets, pacingToleranceFactor, placeholderDefault
# Example values are shown below.

directorConfig:
weights:
proposal_confidence: 0.7
narrative_pacing: 0.15
return_path_confidence: 0.1
player_preference: 0.05
thematic_consistency: 0
lore_adherence: 0
character_voice: 0

pacingTargets:
exposition: 300
rising_action: 400
climax: 700
falling_action: 350
resolution: 300

pacingToleranceFactor: 0.6
placeholderDefault: 0.3

# Default director risk threshold used by the demo UI and Director when not set per-call
# Value between 0.0 (strict) and 1.0 (lenient). Default: 0.4
riskThreshold: 0.4

# Notes:
# - The proxy prefers CLI args, then environment variables, then this file.
# - To expand settings, update scripts/cors-proxy.js and package.json.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ junit-report.xml

# Opencode local temp files
.opencode/tmp/

# Local runtime/editor saves that should not be committed
src/.saves/
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# GEngine

This project supports a local `.gengine/config.yaml` for development overrides. See the `Configuration` section below for how to tune the Director and proxy settings.

GEngine is an InkJS-based interactive story demo (static HTML/JS) with Playwright smoke tests.

## Repository layout (high-level)
Expand All @@ -16,6 +18,9 @@ GEngine is an InkJS-based interactive story demo (static HTML/JS) with Playwrigh
The demo is a static site under `web/` that fetches `web/stories/demo.ink` at runtime.

### Run locally

You can provide local development overrides in `.gengine/config.yaml`. Example keys are in `.gengine/config.example.yaml` and include `directorConfig` for Director tuning.

```bash
npm install
npm run serve-demo
Expand Down
49 changes: 49 additions & 0 deletions docs/dev/DIRECTOR_CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
Director tuning and local configuration

The Director used by the demo supports runtime tuning through a local configuration file at `.gengine/config.yaml`. Use `.gengine/config.example.yaml` as a template.

Supported keys:

- `directorConfig` (mapping): top-level director tuning entry. Contains:
- `weights` (mapping): numeric weights for metrics (defaults shown below).
- `proposal_confidence` (default 0.7)
- `narrative_pacing` (default 0.15)
- `return_path_confidence` (default 0.1)
- `player_preference` (default 0.05)
- `thematic_consistency` (default 0)
- `lore_adherence` (default 0)
- `character_voice` (default 0)
- `pacingTargets` (mapping): expected lengths for narrative phases (defaults shown).
- `exposition: 300`, `rising_action: 400`, `climax: 700`, `falling_action: 350`, `resolution: 300`
- `pacingToleranceFactor` (number): default 0.6
- `placeholderDefault` (number): default 0.3

Examples

Minimal override (change proposal confidence weight):

```yaml
# .gengine/config.yaml
directorConfig:
weights:
proposal_confidence: 0.6
```

Full example (copy from .gengine/config.example.yaml into .gengine/config.yaml and edit values)

Verification

1. Start the dev runner which logs when it loads `.gengine/config.yaml`:

node scripts/dev-runner.js

2. Or quickly print the resolved Director config from Node at the repo root:

node -e "console.log(JSON.stringify(require('./src/runtime/director-config.js'), null, 2))"

3. For browser/demo verification, start the demo server and open the demo UI; the Director will log telemetry events to console and buffer entries in `sessionStorage` (key `ge-hch.director.telemetry`). Changing `directorConfig` values should alter Director decisions in smoke tests or manual playthroughs.

Notes

- `.gengine/config.yaml` is intentionally git-ignored to avoid leaking secrets. Use `.gengine/config.example.yaml` to share recommended defaults.
- Environment variables may override specific director entries using the pattern `DIRECTOR_<SECTION>__<KEY>` (e.g., `DIRECTOR_WEIGHTS__PROPOSAL_CONFIDENCE=0.5`).
46 changes: 46 additions & 0 deletions docs/dev/runtime-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Runtime Hook Points — Usage Guide

Purpose
- Describe supported runtime hook points, example subscriber usage, and demo registration patterns.

Hook points available (demo/runtime)
- pre_inject: emitted before proposal generation/injection begins. Handlers may augment payload or record telemetry.
- post_inject: emitted after a proposal is produced but before it is surfaced to the player.
- pre_checkpoint: emitted synchronously before a checkpoint/save is written; handlers can mutate the save payload.
- post_checkpoint: emitted after a save is persisted; useful for persistence/audit subscribers.
- pre_load: emitted before a load is attempted; handlers may validate or veto the load.
- on_restore: emitted after a successful load/restore.
- on_rollback: emitted when a load fails and a rollback path is taken.
- on_commit / pre_commit / post_commit: beats used around AI branch commit flow (demo emits on_commit after branch play).

Example subscriber (Node/demo)
- File: `src/runtime/subscribers/demo-persistence.js`
- Registers handlers for `post_checkpoint` and `on_rollback` to write debug save artifacts under `src/.saves/` using the project's save-adapter.

Quick registration (demo shim)
- The demo shim will attempt to register demo persistence when running in a bundler/node-like environment:
- `web/demo/js/runtime-hooks-shim.js` contains registration logic that requires `../../src/runtime/subscribers/demo-persistence` and attaches handlers to `window.RuntimeHooks`.

Best practices for subscribers
- Be async/Promise-friendly: HookManager awaits handlers but shields failures; swallow internal errors or return status objects.
- Keep handlers idempotent and non-blocking where possible (e.g., enqueue background writes rather than blocking story progress).
- Sanitize payloads and redact PII before writing logs or telemetry.

Where to look in repo
- HookManager implementation: `src/runtime/hook-manager/index.js`
- README & examples: `src/runtime/hook-manager/README.md`
- Demo shim registration: `web/demo/js/runtime-hooks-shim.js`
- Demo persistence example: `src/runtime/subscribers/demo-persistence.js`

How to run (demo)
1) Serve demo: `npm run serve-demo -- --port 4173`
2) Open: `http://127.0.0.1:4173/demo`
3) Attach a subscriber in console (advanced):
- `window.RuntimeHooks.on('post_checkpoint', p => console.log('post_checkpoint', p));`

Acceptance criteria (docs)
- Clear examples show how to subscribe and where hook points fire.
- Demo shows persistent artifacts under `src/.saves/` when `post_checkpoint` occurs.

Notes
- These hooks are intentionally lightweight. For production usage consider an adapter that forwards telemetry to a remote ingest pipeline and a persistence adapter that uses IndexedDB or host-provided storage for large payloads.
29 changes: 29 additions & 0 deletions docs/runbook/rollback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Rollback Runbook — Demo/Dev

Overview
- This runbook describes how to investigate failed loads and recover player state when the demo reports a rollback.

Steps
1) Reproduce the failure locally using the demo:
- Start demo server: `npm run serve-demo -- --port 4173`
- Open `http://127.0.0.1:4173/demo`
- Corrupt the local save: `localStorage.setItem('ge-hch.smoke.save', 'not-a-json')` and click Load.

2) Inspect rollback artifacts:
- Demo persistence writes debug saves to `src/.saves/` when `post_checkpoint` and `on_rollback` occur.
- List files: `ls -la src/.saves`
- View a file: `cat src/.saves/<file>.save`

3) Inspect integration logs:
- Persistence subscriber also writes integration audit logs to `.runtime_logs/integration.log`.
- `tail -n 200 .runtime_logs/integration.log` to see recent events.

4) Restore a known-good save
- Replace `.saves/<file>.save` content into localStorage key `ge-hch.smoke.save` or use `node` to write a test save using `src/runtime/save-adapter.js`.

5) If corruption is systemic
- Identify the failing component by searching stack traces in browser DevTools and logs.
- If save schema versions mismatched, use the `load-adapter` logic in `src/runtime/load-adapter.js` to determine migration path.

Notes
- Production: migrate to IndexedDB and centralized telemetry ingestion. Demo persistence is for developer debugging only.
10 changes: 10 additions & 0 deletions scripts/build-hook-shim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const fs = require('fs');
const path = require('path');
const src = path.resolve(__dirname, '../src/runtime/hook-manager/index.js');
const out = path.resolve(__dirname, '../web/demo/js/hook-manager.bundled.js');
let srcText = fs.readFileSync(src, 'utf8');
// Remove module.exports line and wrap in IIFE that exposes HookManager constructor
srcText = srcText.replace(/module\.exports\s*=\s*HookManager\s*;?\s*$/m, 'return HookManager;');
const wrapped = `(function(){\n${srcText}\n})();\nif (typeof window !== 'undefined') {\n try {\n const HM = (function(){ ${srcText} })();\n if (!window.RuntimeHooks) window.RuntimeHooks = new HM();\n } catch (e) {\n console.error('[build-hook-shim] failed to initialize HookManager', e);\n }\n}\n`;
fs.writeFileSync(out, wrapped, 'utf8');
console.log('Wrote', out);
19 changes: 19 additions & 0 deletions scripts/generate-director-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Generates web/demo/config/director-config.json from src/runtime/director-config.js
// Run from repo root: node scripts/generate-director-config.js

const fs = require('fs');
const path = require('path');

(function main() {
try {
const cfg = require(path.join('..', 'src', 'runtime', 'director-config.js'));
const outDir = path.join(process.cwd(), 'web', 'demo', 'config');
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, 'director-config.json');
fs.writeFileSync(outPath, JSON.stringify(cfg, null, 2), 'utf8');
console.log('Wrote', outPath);
} catch (err) {
console.error('Failed to generate director config JSON:', err && err.stack || err);
process.exitCode = 2;
}
})();
40 changes: 40 additions & 0 deletions src/runtime/checkpoint/checkpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');

// Simple file-backed checkpoint adapter for demo/testing purposes
// Writes atomic checkpoints using write to temp file and rename

class CheckpointEngine {
constructor(dir = path.join(__dirname, '../../.runtime_checkpoints')) {
this.dir = dir;
if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir, { recursive: true });
}

_tempPath(id) { return path.join(this.dir, `${id}.tmp`); }
_finalPath(id) { return path.join(this.dir, `${id}.chk`); }

writeCheckpoint(id, data) {
const tmp = this._tempPath(id);
const final = this._finalPath(id);
const payload = JSON.stringify({ version: 1, checksum: null, ts: new Date().toISOString(), data });
// compute simple checksum
payload.checksum = null;
fs.writeFileSync(tmp, payload, 'utf8');
fs.renameSync(tmp, final);
return final;
}

readCheckpoint(id) {
const final = this._finalPath(id);
if (!fs.existsSync(final)) throw new Error('Checkpoint not found');
const txt = fs.readFileSync(final, 'utf8');
try {
const obj = JSON.parse(txt);
return obj;
} catch (err) {
throw new Error('Corrupt checkpoint');
}
}
}

module.exports = CheckpointEngine;
124 changes: 124 additions & 0 deletions src/runtime/director-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Director tuning configuration
// Load defaults but allow local overrides from .gengine/config.yaml and environment variables.
// The .gengine/config.yaml may contain a top-level `directorConfig` / `DIRECTOR_CONFIG`
// mapping or individual keys like `weights` / `pacingTargets`.

const fs = (() => {
try { return require('fs'); } catch (e) { return null; }
})();
const path = (() => {
try { return require('path'); } catch (e) { return null; }
})();

let yaml = null;
try { yaml = require('js-yaml'); } catch (e) { yaml = null; }

function deepMerge(target, src) {
if (!src) return target;
Object.keys(src).forEach(k => {
const sv = src[k];
if (sv && typeof sv === 'object' && !Array.isArray(sv) && typeof target[k] === 'object') {
target[k] = deepMerge(Object.assign({}, target[k]), sv);
} else {
target[k] = sv;
}
});
return target;
}

function loadLocalConfig() {
try {
if (!fs || !path) return {};
const cfgPath = path.join(process.cwd(), '.gengine', 'config.yaml');
if (!fs.existsSync(cfgPath)) return {};

const raw = fs.readFileSync(cfgPath, 'utf8');
let parsed = {};
if (yaml) {
parsed = yaml.load(raw) || {};
} else {
// Minimal fallback parser: KEY: value lines
raw.split(/\r?\n/).forEach(line => {
const t = line.trim();
if (!t || t.startsWith('#')) return;
const m = t.match(/^([A-Za-z0-9_\-\.]+)\s*:\s*(.*)$/);
if (m) parsed[m[1]] = m[2];
});
}
return parsed;
} catch (e) {
return {};
}
}

const defaults = {
weights: {
proposal_confidence: 0.7,
narrative_pacing: 0.15,
return_path_confidence: 0.1,
player_preference: 0.05,
thematic_consistency: 0,
lore_adherence: 0,
character_voice: 0
},

pacingTargets: {
exposition: 300,
rising_action: 400,
climax: 700,
falling_action: 350,
resolution: 300
},

pacingToleranceFactor: 0.6,

placeholderDefault: 0.3,

// Global default decision threshold used by the Director when not overridden per-call
// Value is in 0.0..1.0 where lower is stricter (default 0.4)
riskThreshold: 0.4
};

// Attempt to load local overrides
const local = loadLocalConfig();
let merged = Object.assign({}, defaults);

// Support several possible shapes in the YAML: a top-level directorConfig, or
// top-level keys (weights, pacingTargets, etc.).
if (local) {
const c = local.directorConfig || local.DIRECTOR_CONFIG || local.DirectorConfig || null;
if (c && typeof c === 'object') {
merged = deepMerge(merged, c);
} else {
// Merge any matching top-level keys
['weights', 'pacingTargets', 'pacingToleranceFactor', 'placeholderDefault'].forEach(k => {
if (Object.prototype.hasOwnProperty.call(local, k)) {
merged = deepMerge(merged, { [k]: local[k] });
}
const upk = String(k).toUpperCase();
if (Object.prototype.hasOwnProperty.call(local, upk)) {
merged = deepMerge(merged, { [k]: local[upk] });
}
});
}
}

// Environment variables may also override individual values (optional).
// For example: process.env.DIRECTOR_WEIGHTS__PROPOSAL_CONFIDENCE=0.5
if (typeof process !== 'undefined' && process.env) {
Object.keys(process.env).forEach(envK => {
// pattern: DIRECTOR_WEIGHTS__proposal_confidence or DIRECTOR_PACINGTARGETS__exposition
const m = envK.match(/^DIRECTOR_([A-Z0-9_]+)__([A-Z0-9_]+)$/);
if (m) {
const section = m[1].toLowerCase();
const key = m[2].toLowerCase();
try {
const val = Number(process.env[envK]);
if (!Number.isNaN(val)) merged[section] = merged[section] || {}, merged[section][key] = val;
else merged[section] = merged[section] || {}, merged[section][key] = process.env[envK];
} catch (e) {}
}
});
}

module.exports = merged;
Loading