playable-lessons 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,10 @@ Turn educational source material into **playable interactive fiction**. Paste in
4
4
 
5
5
  It ships in two forms:
6
6
 
7
- - **Desktop app** (Electron + React) — the full authoring experience: AI generation pipeline, node-graph editor, live player, and exports. Aimed at non-technical authors (e.g. educators).
8
- - **CLI** (`playable-lessons`, on npm) — a headless tool for **validating and exporting** existing `.ink` files. Handy for scripts, CI, and batch export.
7
+ - **Desktop app** (Electron + React) — the full authoring experience: AI generation with an interactive clarification step, a node-graph editor, live player, and exports. Aimed at non-technical authors (e.g. educators).
8
+ - **CLI** (`playable-lessons`, on npm) — the same engine, headless: **generate**, **validate**, and **export**. Handy for scripts, CI, batch runs, and local/offline generation against Ollama.
9
9
 
10
- > **Note:** AI _generation_ currently lives in the desktop app only. The CLI does **not** generate stories or call any LLM — it validates and exports Ink you already have. (A headless `generate` command is on the roadmap.)
10
+ The desktop app and the CLI share one generation pipeline; the app adds the interactive clarification step and visual editor on top.
11
11
 
12
12
  ---
13
13
 
@@ -19,6 +19,33 @@ It ships in two forms:
19
19
  npm install -g playable-lessons
20
20
  ```
21
21
 
22
+ ### Generate a story from source material
23
+
24
+ ```bash
25
+ # Local + offline against Ollama (no API key needed):
26
+ playable-lessons generate --input notes.md --provider ollama --model llama3.1:8b --output lesson/
27
+
28
+ # Anthropic Claude (key from the environment):
29
+ ANTHROPIC_API_KEY=sk-ant-... playable-lessons generate -i case-study.md -p claude -o lesson/
30
+
31
+ # From a topic instead of a file, choosing formats:
32
+ playable-lessons generate --topic "Phishing awareness for staff" -p openai -o lesson/ -f ink,html,json
33
+
34
+ # Pipe source material via stdin:
35
+ cat lecture.txt | playable-lessons generate -o lesson/ -p ollama -m llama3.1:8b
36
+ ```
37
+
38
+ Provider selection and API keys:
39
+
40
+ | Provider | `--provider` | Key (env var) | Notes |
41
+ | --- | --- | --- | --- |
42
+ | Claude | `claude` | `ANTHROPIC_API_KEY` | default model `claude-opus-4-8` |
43
+ | OpenAI | `openai` | `OPENAI_API_KEY` | default model `gpt-4o` |
44
+ | Ollama | `ollama` | none (or `OLLAMA_API_KEY`) | local by default; `--ollama-url` for remote |
45
+ | Custom | `custom` | `PLAYABLE_LESSONS_API_KEY` / `OPENAI_API_KEY` | OpenAI-compatible; requires `--base-url` + `--model` |
46
+
47
+ If `--provider` is omitted it's inferred from the environment (Claude → OpenAI → Ollama). Keys are read from env vars only — the CLI never touches the OS keychain (that's the desktop app). Other flags: `--mode`, `--length` (short/medium/long), `--tone`, `--protagonist`, `--answers`, `--title`. Run `playable-lessons generate --help` for all options.
48
+
22
49
  ### Validate an Ink file
23
50
 
24
51
  ```bash
package/out/cli/index.js CHANGED
@@ -9,6 +9,67 @@ const helpers_1 = require("yargs/helpers");
9
9
  const promises_1 = require("fs/promises");
10
10
  const path_1 = require("path");
11
11
  const storyExport_js_1 = require("../shared/storyExport.js");
12
+ const generate_js_1 = require("../shared/generate.js");
13
+ /** Pick a provider from the environment when --provider isn't given. */
14
+ function resolveProviderFromEnv() {
15
+ if (process.env.ANTHROPIC_API_KEY)
16
+ return 'claude';
17
+ if (process.env.OPENAI_API_KEY)
18
+ return 'openai';
19
+ return 'ollama';
20
+ }
21
+ /**
22
+ * Build a ProviderConfig from CLI flags + environment. API keys come from
23
+ * env vars (CI/headless-friendly), never the OS keychain (that's GUI-only).
24
+ */
25
+ function buildCliConfig(o) {
26
+ const provider = o.provider || resolveProviderFromEnv();
27
+ switch (provider) {
28
+ case 'openai': {
29
+ const apiKey = process.env.OPENAI_API_KEY;
30
+ if (!apiKey)
31
+ throw new Error('OPENAI_API_KEY is not set');
32
+ return { provider, model: o.model || 'gpt-4o', apiKey };
33
+ }
34
+ case 'custom': {
35
+ if (!o.baseUrl)
36
+ throw new Error('--base-url is required for the custom provider');
37
+ if (!o.model)
38
+ throw new Error('--model is required for the custom provider');
39
+ return {
40
+ provider,
41
+ model: o.model,
42
+ baseUrl: o.baseUrl,
43
+ apiKey: process.env.PLAYABLE_LESSONS_API_KEY || process.env.OPENAI_API_KEY || ''
44
+ };
45
+ }
46
+ case 'ollama':
47
+ return {
48
+ provider,
49
+ model: o.model || 'llama3.1:8b',
50
+ ollamaUrl: o.ollamaUrl,
51
+ ollamaToken: process.env.OLLAMA_API_KEY || ''
52
+ };
53
+ case 'claude':
54
+ default: {
55
+ const apiKey = process.env.ANTHROPIC_API_KEY;
56
+ if (!apiKey)
57
+ throw new Error('ANTHROPIC_API_KEY is not set');
58
+ return { provider: 'claude', model: o.model || 'claude-opus-4-8', apiKey };
59
+ }
60
+ }
61
+ }
62
+ function slug(s) {
63
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'story';
64
+ }
65
+ async function readStdin() {
66
+ if (process.stdin.isTTY)
67
+ return '';
68
+ const chunks = [];
69
+ for await (const chunk of process.stdin)
70
+ chunks.push(Buffer.from(chunk));
71
+ return Buffer.concat(chunks).toString('utf-8');
72
+ }
12
73
  (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
13
74
  .scriptName('playable-lessons')
14
75
  .usage('$0 <command> [options]')
@@ -70,8 +131,97 @@ const storyExport_js_1 = require("../shared/storyExport.js");
70
131
  console.error('Export failed:', err instanceof Error ? err.message : err);
71
132
  process.exit(1);
72
133
  }
134
+ })
135
+ .command('generate', 'Generate an Ink story from source material using AI', (yargs) => {
136
+ return yargs
137
+ .option('input', { alias: 'i', type: 'string', describe: 'Path to source material (text/markdown). Omit to use --topic or stdin.' })
138
+ .option('topic', { type: 'string', describe: 'A topic or brief to generate from (instead of --input)' })
139
+ .option('output', { alias: 'o', type: 'string', demandOption: true, describe: 'Output directory' })
140
+ .option('provider', { alias: 'p', type: 'string', describe: 'claude | openai | ollama | custom (default: inferred from env)' })
141
+ .option('model', { alias: 'm', type: 'string', describe: 'Model id (provider-specific default if omitted)' })
142
+ .option('base-url', { type: 'string', describe: 'Base URL for a custom OpenAI-compatible endpoint' })
143
+ .option('ollama-url', { type: 'string', default: 'http://localhost:11434', describe: 'Ollama base URL' })
144
+ .option('mode', { type: 'string', default: 'topic', describe: 'Input mode: topic | lesson | methodology | case-study | lecture-notes | scenario' })
145
+ .option('length', { alias: 'l', type: 'string', default: 'medium', describe: 'Story length: short | medium | long' })
146
+ .option('tone', { type: 'string', default: 'professional', describe: 'Narrative tone' })
147
+ .option('protagonist', { type: 'string', default: 'the reader', describe: 'Protagonist type' })
148
+ .option('answers', { type: 'string', describe: 'Optional answers to clarifying questions' })
149
+ .option('retries', { type: 'number', default: 3, describe: 'Max compile attempts, with AI-assisted fixes between them (raise for weaker local models)' })
150
+ .option('format', { alias: 'f', type: 'string', default: 'ink,html', describe: 'Output formats: ink, html, json' })
151
+ .option('title', { alias: 't', type: 'string', describe: 'Story title (defaults to the input/topic name)' });
152
+ }, async (argv) => {
153
+ try {
154
+ // Resolve the source material: --input file, --topic string, or stdin.
155
+ let inputText = '';
156
+ let base = 'story';
157
+ if (argv.input) {
158
+ inputText = await (0, promises_1.readFile)(argv.input, 'utf-8');
159
+ base = (0, path_1.basename)(argv.input).replace(/\.[^.]+$/, '');
160
+ }
161
+ else if (argv.topic) {
162
+ inputText = argv.topic;
163
+ base = slug(argv.topic);
164
+ }
165
+ else {
166
+ inputText = await readStdin();
167
+ }
168
+ if (!inputText.trim()) {
169
+ throw new Error('No source material — provide --input <file>, --topic "<brief>", or pipe text via stdin');
170
+ }
171
+ const config = buildCliConfig({
172
+ provider: argv.provider,
173
+ model: argv.model,
174
+ baseUrl: argv['base-url'],
175
+ ollamaUrl: argv['ollama-url'] || 'http://localhost:11434'
176
+ });
177
+ console.error(`Generating with ${config.provider} (${config.model})…`);
178
+ const result = await (0, generate_js_1.generateInk)({
179
+ inputMode: argv.mode,
180
+ inputText,
181
+ storyLength: argv.length,
182
+ protagonistType: argv.protagonist,
183
+ tone: argv.tone,
184
+ answers: argv.answers
185
+ }, config, { log: (m) => console.error(m), maxCompileRetries: argv.retries });
186
+ const outputDir = argv.output;
187
+ await (0, promises_1.mkdir)(outputDir, { recursive: true });
188
+ const title = argv.title || base;
189
+ const formats = argv.format.split(',').map((f) => f.trim()).filter(Boolean);
190
+ if (!formats.includes('ink'))
191
+ formats.unshift('ink'); // always keep the source
192
+ for (const format of formats) {
193
+ switch (format) {
194
+ case 'ink': {
195
+ const outPath = (0, path_1.join)(outputDir, `${base}.ink`);
196
+ await (0, promises_1.writeFile)(outPath, result.inkSource);
197
+ console.log(`Wrote ${outPath}`);
198
+ break;
199
+ }
200
+ case 'json': {
201
+ const outPath = (0, path_1.join)(outputDir, `${base}.ink.json`);
202
+ await (0, promises_1.writeFile)(outPath, result.compiledJson);
203
+ console.log(`Wrote ${outPath}`);
204
+ break;
205
+ }
206
+ case 'html': {
207
+ const html = await (0, storyExport_js_1.exportStandaloneHTML)(result.inkSource, title);
208
+ const outPath = (0, path_1.join)(outputDir, `${base}.html`);
209
+ await (0, promises_1.writeFile)(outPath, html);
210
+ console.log(`Wrote ${outPath}`);
211
+ break;
212
+ }
213
+ default:
214
+ console.warn(`Unknown format: ${format}`);
215
+ }
216
+ }
217
+ console.log('Done.');
218
+ }
219
+ catch (err) {
220
+ console.error('Generation failed:', err instanceof Error ? err.message : err);
221
+ process.exit(1);
222
+ }
73
223
  })
74
224
  .demandCommand(1, 'You must specify a command')
75
225
  .help()
76
- .version('0.1.0')
226
+ .version('0.2.1')
77
227
  .parse();
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ /**
3
+ * Provider-agnostic AI client, shared by the renderer and the CLI.
4
+ *
5
+ * Pure and config-driven: callers pass a ProviderConfig (no Zustand store, no
6
+ * DOM). Uses the global `fetch`/`AbortController`, available in both the
7
+ * browser (renderer) and Node 18+ (CLI).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.normalizeBaseUrl = normalizeBaseUrl;
11
+ exports.callAI = callAI;
12
+ exports.listModels = listModels;
13
+ exports.testConnection = testConnection;
14
+ // AI generations can be slow, but should not hang forever.
15
+ const REQUEST_TIMEOUT_MS = 120_000;
16
+ async function fetchWithTimeout(url, options, timeoutMs = REQUEST_TIMEOUT_MS) {
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+ try {
20
+ return await fetch(url, { ...options, signal: controller.signal });
21
+ }
22
+ catch (err) {
23
+ if (err instanceof Error && err.name === 'AbortError') {
24
+ throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
25
+ }
26
+ throw err;
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+ function anthropicHeaders(apiKey) {
33
+ return {
34
+ 'Content-Type': 'application/json',
35
+ 'x-api-key': apiKey,
36
+ 'anthropic-version': '2023-06-01',
37
+ 'anthropic-dangerous-direct-browser-access': 'true'
38
+ };
39
+ }
40
+ /** Bearer headers, omitting Authorization when no token is configured. */
41
+ function bearerHeaders(token) {
42
+ const headers = { 'Content-Type': 'application/json' };
43
+ if (token)
44
+ headers['Authorization'] = `Bearer ${token}`;
45
+ return headers;
46
+ }
47
+ /** Strip trailing slashes so `${base}/chat/completions` is well-formed. */
48
+ function normalizeBaseUrl(url) {
49
+ return url.trim().replace(/\/+$/, '');
50
+ }
51
+ const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
52
+ /** Send a chat completion to whichever provider the config selects. */
53
+ async function callAI(messages, config) {
54
+ switch (config.provider) {
55
+ case 'claude':
56
+ return callClaude(messages, config.apiKey ?? '', config.model);
57
+ case 'openai':
58
+ return callOpenAICompatible(messages, config.apiKey ?? '', 'https://api.openai.com/v1', config.model);
59
+ case 'custom':
60
+ return callOpenAICompatible(messages, config.apiKey ?? '', normalizeBaseUrl(config.baseUrl ?? ''), config.model);
61
+ case 'ollama':
62
+ default:
63
+ return callOllama(messages, config.ollamaUrl ?? DEFAULT_OLLAMA_URL, config.model, config.ollamaToken ?? '');
64
+ }
65
+ }
66
+ async function callClaude(messages, apiKey, model) {
67
+ const systemMsg = messages.find((m) => m.role === 'system')?.content || '';
68
+ const nonSystemMessages = messages.filter((m) => m.role !== 'system');
69
+ const response = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
70
+ method: 'POST',
71
+ headers: anthropicHeaders(apiKey),
72
+ body: JSON.stringify({
73
+ model,
74
+ max_tokens: 8192,
75
+ system: systemMsg,
76
+ messages: nonSystemMessages.map((m) => ({ role: m.role, content: m.content }))
77
+ })
78
+ });
79
+ if (!response.ok) {
80
+ throw new Error(`Claude API error (${response.status}): ${await response.text()}`);
81
+ }
82
+ const data = await response.json();
83
+ const text = data?.content?.[0]?.text;
84
+ if (typeof text !== 'string')
85
+ throw new Error('Claude API returned an unexpected response shape');
86
+ return text;
87
+ }
88
+ /**
89
+ * Any OpenAI-compatible /chat/completions endpoint — OpenAI itself, plus custom
90
+ * gateways (OpenRouter, LiteLLM, vLLM, a remote Ollama, …) behind a bearer token.
91
+ */
92
+ async function callOpenAICompatible(messages, apiKey, baseUrl, model) {
93
+ if (!baseUrl)
94
+ throw new Error('No base URL configured for the OpenAI-compatible endpoint');
95
+ const response = await fetchWithTimeout(`${baseUrl}/chat/completions`, {
96
+ method: 'POST',
97
+ headers: bearerHeaders(apiKey),
98
+ body: JSON.stringify({ model, messages, max_tokens: 8192 })
99
+ });
100
+ if (!response.ok) {
101
+ throw new Error(`API error (${response.status}): ${await response.text()}`);
102
+ }
103
+ const data = await response.json();
104
+ const content = data?.choices?.[0]?.message?.content;
105
+ if (typeof content !== 'string')
106
+ throw new Error('API returned an unexpected response shape');
107
+ return content;
108
+ }
109
+ async function callOllama(messages, url, model, token) {
110
+ const response = await fetchWithTimeout(`${normalizeBaseUrl(url)}/api/chat`, {
111
+ method: 'POST',
112
+ headers: bearerHeaders(token),
113
+ body: JSON.stringify({ model, messages, stream: false })
114
+ });
115
+ if (!response.ok) {
116
+ throw new Error(`Ollama error (${response.status}): ${await response.text()}`);
117
+ }
118
+ const data = await response.json();
119
+ const content = data?.message?.content;
120
+ if (typeof content !== 'string')
121
+ throw new Error('Ollama returned an unexpected response shape');
122
+ return content;
123
+ }
124
+ // ─── Model discovery & connection testing ───
125
+ /** List available models for the configured provider. */
126
+ async function listModels(config) {
127
+ switch (config.provider) {
128
+ case 'claude':
129
+ return listAnthropicModels(config.apiKey ?? '');
130
+ case 'openai':
131
+ return listOpenAICompatibleModels('https://api.openai.com/v1', config.apiKey ?? '');
132
+ case 'custom':
133
+ return listOpenAICompatibleModels(normalizeBaseUrl(config.baseUrl ?? ''), config.apiKey ?? '');
134
+ case 'ollama':
135
+ default:
136
+ return listOllamaModels(config.ollamaUrl ?? DEFAULT_OLLAMA_URL, config.ollamaToken ?? '');
137
+ }
138
+ }
139
+ async function listAnthropicModels(apiKey) {
140
+ const response = await fetchWithTimeout('https://api.anthropic.com/v1/models?limit=100', {
141
+ method: 'GET',
142
+ headers: anthropicHeaders(apiKey)
143
+ }, 30_000);
144
+ if (!response.ok)
145
+ throw new Error(`Claude API error (${response.status}): ${await response.text()}`);
146
+ const data = await response.json();
147
+ return (data?.data ?? []).map((m) => m.id).filter(Boolean);
148
+ }
149
+ async function listOpenAICompatibleModels(baseUrl, apiKey) {
150
+ if (!baseUrl)
151
+ throw new Error('No base URL configured');
152
+ const response = await fetchWithTimeout(`${baseUrl}/models`, {
153
+ method: 'GET',
154
+ headers: bearerHeaders(apiKey)
155
+ }, 30_000);
156
+ if (!response.ok)
157
+ throw new Error(`API error (${response.status}): ${await response.text()}`);
158
+ const data = await response.json();
159
+ return (data?.data ?? []).map((m) => m.id).filter(Boolean).sort();
160
+ }
161
+ async function listOllamaModels(url, token) {
162
+ const response = await fetchWithTimeout(`${normalizeBaseUrl(url)}/api/tags`, {
163
+ method: 'GET',
164
+ headers: bearerHeaders(token)
165
+ }, 30_000);
166
+ if (!response.ok)
167
+ throw new Error(`Ollama error (${response.status}): ${await response.text()}`);
168
+ const data = await response.json();
169
+ return (data?.models ?? []).map((m) => m.name).filter(Boolean);
170
+ }
171
+ /**
172
+ * Probe the configured endpoint + credentials by listing models. Doubles as the
173
+ * "Test connection" check and the source for "Refresh models".
174
+ */
175
+ async function testConnection(config) {
176
+ try {
177
+ const models = await listModels(config);
178
+ return { ok: true, message: `Connected — ${models.length} model(s) available.`, models };
179
+ }
180
+ catch (err) {
181
+ return { ok: false, message: err instanceof Error ? err.message : 'Connection failed', models: [] };
182
+ }
183
+ }
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * Headless story generation pipeline, shared by the renderer and the CLI.
4
+ *
5
+ * Unlike the renderer's interactive flow (which pauses to ask the user
6
+ * clarification questions), this runs end-to-end with no interaction:
7
+ * outline → Ink generation → review → compile (with AI-assisted fix retries).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.extractInk = extractInk;
11
+ exports.generateInk = generateInk;
12
+ const aiClient_js_1 = require("./aiClient.js");
13
+ const prompts_js_1 = require("./prompts.js");
14
+ const storyExport_js_1 = require("./storyExport.js");
15
+ /** Pull Ink source out of an AI response — prefer a ```ink block, then any fence. */
16
+ function extractInk(raw) {
17
+ const inkBlock = raw.match(/```ink\s*([\s\S]*?)```/);
18
+ if (inkBlock)
19
+ return inkBlock[1].trim();
20
+ const anyBlock = raw.match(/```\s*([\s\S]*?)```/);
21
+ if (anyBlock)
22
+ return anyBlock[1].trim();
23
+ return raw.trim();
24
+ }
25
+ /** Match a corrected ```ink block from a review/fix response (only `ink` fences). */
26
+ function extractInkCorrection(raw) {
27
+ const m = raw.match(/```ink\s*([\s\S]*?)```/);
28
+ return m ? m[1].trim() : null;
29
+ }
30
+ async function generateInk(params, config, opts = {}) {
31
+ const log = opts.log ?? (() => { });
32
+ const call = opts.call ?? ((messages) => (0, aiClient_js_1.callAI)(messages, config));
33
+ const maxRetries = opts.maxCompileRetries ?? 3;
34
+ const mode = params.inputMode;
35
+ const length = params.storyLength || 'medium';
36
+ const protagonist = params.protagonistType || 'the reader';
37
+ const tone = params.tone || 'professional';
38
+ const answers = params.answers || '(no specific preferences provided — use sensible defaults)';
39
+ const inkSystem = prompts_js_1.PROMPTS.system + '\n\n' + prompts_js_1.PROMPTS.inkSyntaxRef;
40
+ // ─── Stage 1: Outline ───
41
+ log('[1/4] Generating story outline…');
42
+ const outlineRaw = await call([
43
+ { role: 'system', content: prompts_js_1.PROMPTS.system },
44
+ {
45
+ role: 'user',
46
+ content: prompts_js_1.PROMPTS.outline
47
+ .replace('{{inputMode}}', mode)
48
+ .replace('{{inputText}}', params.inputText)
49
+ .replace('{{storyLength}}', length)
50
+ .replace('{{answers}}', answers)
51
+ .replace('{{protagonistType}}', protagonist)
52
+ .replace('{{tone}}', tone)
53
+ }
54
+ ]);
55
+ // ─── Stage 2: Ink generation ───
56
+ log('[2/4] Generating Ink source…');
57
+ const inkRaw = await call([
58
+ { role: 'system', content: inkSystem },
59
+ {
60
+ role: 'user',
61
+ content: prompts_js_1.PROMPTS.inkGeneration
62
+ .replace('{{outline}}', outlineRaw)
63
+ .replace('{{inputMode}}', mode)
64
+ .replace('{{inputText}}', params.inputText)
65
+ .replace('{{storyLength}}', length)
66
+ }
67
+ ]);
68
+ let inkSource = extractInk(inkRaw);
69
+ // ─── Stage 3: Review pass ───
70
+ log('[3/4] Reviewing for errors and inconsistencies…');
71
+ const reviewRaw = await call([
72
+ { role: 'system', content: inkSystem },
73
+ { role: 'user', content: prompts_js_1.PROMPTS.review.replace('{{inkSource}}', inkSource) }
74
+ ]);
75
+ const corrected = extractInkCorrection(reviewRaw);
76
+ // Only accept the rewrite if it retains at least half the original's length
77
+ // (guards against a truncated/partial snippet).
78
+ if (corrected && corrected.length > inkSource.length * 0.5) {
79
+ inkSource = corrected;
80
+ log('Applied corrections from review.');
81
+ }
82
+ // ─── Stage 4: Compile, with AI-assisted fix retries ───
83
+ log('[4/4] Compiling Ink source…');
84
+ let current = inkSource;
85
+ for (let attempt = 1;; attempt++) {
86
+ try {
87
+ const compiledJson = await (0, storyExport_js_1.compileInk)(current);
88
+ log('Compiled successfully.');
89
+ return { inkSource: current, compiledJson, outlineRaw };
90
+ }
91
+ catch (err) {
92
+ const errMsg = err instanceof Error ? err.message : String(err);
93
+ if (attempt >= maxRetries) {
94
+ throw new Error(`Failed to compile after ${maxRetries} attempt(s): ${errMsg}`);
95
+ }
96
+ log(`Compilation failed (attempt ${attempt}/${maxRetries}): ${errMsg}`);
97
+ log('Asking the model to fix it…');
98
+ const fixRaw = await call([
99
+ { role: 'system', content: inkSystem },
100
+ {
101
+ role: 'user',
102
+ content: `The following Ink source failed to compile with this error:\n\n${errMsg}\n\n` +
103
+ `Here is the Ink source:\n\`\`\`ink\n${current}\n\`\`\`\n\n` +
104
+ 'Please fix the errors and return the complete corrected Ink source in a ```ink code block.'
105
+ }
106
+ ]);
107
+ const fixed = extractInkCorrection(fixRaw);
108
+ if (fixed)
109
+ current = fixed;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PROMPTS = void 0;
4
+ exports.PROMPTS = {
5
+ system: `You are the Playable Lessons assistant, an AI that transforms educational source material into playable interactive fiction using the Ink scripting language.
6
+
7
+ Your goals:
8
+ - Create engaging, educational interactive stories from the provided source material
9
+ - Ensure all generated Ink code is syntactically valid and compiles without errors
10
+ - Preserve factual accuracy from the source material
11
+ - Never invent facts about named real people or institutions
12
+ - Create meaningful branching choices that explore different perspectives and outcomes
13
+ - Track relevant variables (experience, trust, knowledge) to make the story feel responsive
14
+
15
+ When generating Ink, follow these rules:
16
+ - Always start with variable declarations (VAR), then a -> start divert to the first knot
17
+ - The first knot MUST be named === start ===
18
+ - Use === knot_name === for passages (knots)
19
+ - Use = stitch_name for sub-sections within knots
20
+ - Use * for once-only choices, + for sticky choices
21
+ - Use -> knot_name for diverts
22
+ - Use { variable > X: text } for conditional content
23
+ - Use ~ variable = value for variable assignments
24
+ - Always include a -> END or -> DONE to terminate paths
25
+ - Use # IMAGE: and # TIMER: tags for Playable Lessons custom features`,
26
+ analysis: `Analyze the following source material provided in "{{inputMode}}" mode.
27
+
28
+ Source material:
29
+ """
30
+ {{inputText}}
31
+ """
32
+
33
+ Extract and list:
34
+ 1. The main subject/theme
35
+ 2. Key concepts and decision points
36
+ 3. Potential protagonist(s) and setting
37
+ 4. Learning outcomes (what should the reader understand after playing?)
38
+ 5. Factual claims that must be preserved accurately
39
+ 6. Potential branching points where different choices could lead to different outcomes
40
+
41
+ Provide your analysis in a clear, structured format.`,
42
+ clarification: `Based on this analysis of the source material:
43
+
44
+ {{analysis}}
45
+
46
+ The input mode is: {{inputMode}}
47
+
48
+ Generate 3-5 clarification questions to ask the user before generating the story. Questions should cover:
49
+ - Who is the protagonist? (generic professional / named character / "you" the reader)
50
+ - What is the primary learning outcome?
51
+ - What tone? (professional/realistic, light/exploratory, challenging/provocative)
52
+ - Should there be a "best path" or are all paths equally valid?
53
+ - Any specific constraints or requirements?
54
+
55
+ Format each question as a numbered list item.`,
56
+ outline: `Create a detailed story outline for an interactive fiction based on this:
57
+
58
+ Input mode: {{inputMode}}
59
+ Source material: """{{inputText}}"""
60
+ Story length: {{storyLength}} (short=~5 nodes, medium=~15, long=~30+)
61
+ Protagonist type: {{protagonistType}}
62
+ Tone: {{tone}}
63
+
64
+ User's answers to clarification questions:
65
+ {{answers}}
66
+
67
+ Generate a JSON outline with this structure:
68
+ \`\`\`json
69
+ {
70
+ "nodes": [
71
+ { "id": "start", "title": "Opening Scene", "summary": "Brief description" }
72
+ ],
73
+ "edges": [
74
+ { "from": "start", "to": "next_node", "choiceText": "What the player clicks" }
75
+ ],
76
+ "variables": [
77
+ { "name": "experience", "type": "number", "initialValue": 0 }
78
+ ],
79
+ "canonPath": ["start", "node2", "node3", "best_ending"]
80
+ }
81
+ \`\`\`
82
+
83
+ Ensure:
84
+ - Every node is reachable from "start"
85
+ - Every path reaches an ending node
86
+ - Variables are used meaningfully to track decisions
87
+ - The story has at least 2 distinct endings
88
+ - The canon path represents the "ideal" learning path`,
89
+ inkGeneration: `Convert this story outline into valid Ink source code.
90
+
91
+ Outline:
92
+ {{outline}}
93
+
94
+ Original source material (for factual accuracy):
95
+ Input mode: {{inputMode}}
96
+ """{{inputText}}"""
97
+
98
+ Target length: {{storyLength}}
99
+
100
+ Requirements:
101
+ - Begin with VAR declarations for all tracked variables
102
+ - Each node from the outline becomes an === knot ===
103
+ - Each edge becomes a choice (* [choice text])
104
+ - Include variable assignments (~ var = val) where decisions affect state
105
+ - Use conditional text { var > X: text } to make scenes respond to past choices
106
+ - Every path must reach -> END
107
+ - Write engaging, educational prose — not just placeholder text
108
+ - Write ALL prose yourself. Do NOT copy any example or placeholder text from the
109
+ syntax reference verbatim (e.g. "[Write the opening scene here]"); those
110
+ bracketed placeholders show structure only — replace them with real content
111
+ - Include at least one conditional branch that depends on a variable
112
+ - Mark endings with a # ENDING: tag followed by a label (good/neutral/bad)
113
+
114
+ Return ONLY the complete Ink source inside a \`\`\`ink code block.`,
115
+ review: `Review the following Ink source for errors, inconsistencies, dead ends, and unreachable nodes.
116
+
117
+ \`\`\`ink
118
+ {{inkSource}}
119
+ \`\`\`
120
+
121
+ Check for:
122
+ 1. Syntax errors (unclosed braces, missing diverts, invalid variable references)
123
+ 2. Dead ends (passages with no choices and no -> END/DONE)
124
+ 3. Unreachable nodes (knots that nothing diverts to)
125
+ 4. Variable inconsistencies (used but not declared, or declared but never used)
126
+ 5. Missing endings (paths that don't terminate)
127
+ 6. Narrative inconsistencies (contradictions, tone shifts)
128
+
129
+ If you find issues, return the corrected complete Ink source in a \`\`\`ink code block.
130
+ If the source is clean, respond with "No issues found."`,
131
+ inkSyntaxRef: `=== INK SYNTAX REFERENCE ===
132
+
133
+ // Variable declarations (top of file), then divert to first knot
134
+ VAR name = "value"
135
+ VAR score = 0
136
+ VAR has_key = false
137
+ -> start
138
+
139
+ // First knot MUST be named "start"
140
+ // (bracketed text below = placeholders — replace with your own prose, do not copy)
141
+ === start ===
142
+ [Write the opening scene here]
143
+ * [First choice] -> next_scene
144
+
145
+ // Other knots
146
+ === knot_name ===
147
+ [Write the passage prose here]
148
+ * [Choice text visible to player]
149
+ [Result prose shown after choosing]
150
+ -> next_knot
151
+ * [Another choice]
152
+ -> another_knot
153
+
154
+ // Stitches (sub-sections within a knot)
155
+ === knot ===
156
+ = stitch_one
157
+ [Stitch prose here]
158
+ -> stitch_two
159
+ = stitch_two
160
+ [More stitch prose here]
161
+
162
+ // Sticky choices (can be chosen repeatedly)
163
+ + [Repeatable choice]
164
+
165
+ // Conditional text
166
+ { score > 5:
167
+ You've done well.
168
+ - else:
169
+ You need more experience.
170
+ }
171
+
172
+ // Inline conditional
173
+ You feel {has_key: confident|nervous}.
174
+
175
+ // Variable assignment
176
+ ~ score = score + 1
177
+ ~ has_key = true
178
+
179
+ // Diverts
180
+ -> knot_name
181
+ -> END
182
+ -> DONE
183
+
184
+ // Tags (for Playable Lessons)
185
+ # IMAGE: assets/images/scene.jpg
186
+ # TIMER: 15
187
+ # ENDING: good
188
+
189
+ // Glue (prevents line break)
190
+ Text<>
191
+ <>more text on same line
192
+
193
+ === END ===`
194
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playable-lessons",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Interactive Fiction Authoring Tool for Education — Generate playable branching stories from source material using AI + Ink",
5
5
  "main": "./out/main/index.mjs",
6
6
  "scripts": {