pi-demo-mode 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dutifuldev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pi-demo-mode
2
+
3
+ pi-demo-mode is a [Pi](https://github.com/earendil-works/pi) extension package
4
+ that puts Pi into a self-driving demo mode. It sends a story prompt when the
5
+ session starts, keeps asking the model to continue after every turn (compacting
6
+ the session when the context fills up), and strips the TUI down to the chat
7
+ plus a single status line — no input bar, no directory line, no context meter.
8
+ The result is a terminal that endlessly writes a story on its own, which is
9
+ what you want for screen recordings and demo walls.
10
+
11
+ ## Install
12
+
13
+ Into a plain Pi setup:
14
+
15
+ ```bash
16
+ pi install github:osolmaz/pi-demo-mode
17
+ ```
18
+
19
+ Or as an npm git dependency of a Pi launcher, pointing Pi at
20
+ `extensions/demo-mode.ts` inside the installed package.
21
+
22
+ ## Usage
23
+
24
+ The extension is inert until it is enabled, so it can stay installed for
25
+ interactive sessions. Configuration is entirely through environment
26
+ variables:
27
+
28
+ | Variable | Meaning |
29
+ | ------------------------------ | --------------------------------------------------------------- |
30
+ | `PI_DEMO_MODE=1` | enable demo mode |
31
+ | `PI_DEMO_INITIAL_PROMPT` | first prompt text |
32
+ | `PI_DEMO_INITIAL_PROMPT_FILE` | UTF-8 file with the first prompt (wins over the inline text) |
33
+ | `PI_DEMO_FOLLOWUP_PROMPT` | repeated prompt after each turn |
34
+ | `PI_DEMO_FOLLOWUP_PROMPT_FILE` | UTF-8 file with the repeated prompt (wins over the inline text) |
35
+
36
+ Without prompt overrides, a built-in never-ending sci-fi story prompt is
37
+ used. Run Pi with tools disabled so nothing interrupts the story:
38
+
39
+ ```bash
40
+ PI_DEMO_MODE=1 pi --no-tools --no-approve
41
+ ```
42
+
43
+ The demo stops on its own when a turn is aborted or errors, and treats any
44
+ message you type as a live director note for the story.
45
+
46
+ ## Used by
47
+
48
+ - [localpi](https://github.com/osolmaz/localpi) (`localpi --demo`)
49
+ - [diffusionpi](https://github.com/osolmaz/diffusionpi) (`diffusionpi demo`)
50
+
51
+ ## License
52
+
53
+ [MIT](LICENSE)
@@ -0,0 +1,233 @@
1
+ // Self-driving demo mode for Pi: sends a story prompt on startup, keeps
2
+ // asking the model to continue after every turn, and strips the TUI down to
3
+ // the chat plus a single status line. Everything is gated on PI_DEMO_MODE=1
4
+ // so the extension can stay installed in interactive sessions without doing
5
+ // anything.
6
+ //
7
+ // Configuration (all optional):
8
+ // PI_DEMO_MODE=1 enable demo mode
9
+ // PI_DEMO_INITIAL_PROMPT first prompt text
10
+ // PI_DEMO_INITIAL_PROMPT_FILE UTF-8 file with the first prompt
11
+ // PI_DEMO_FOLLOWUP_PROMPT repeated prompt text after each turn
12
+ // PI_DEMO_FOLLOWUP_PROMPT_FILE UTF-8 file with the repeated prompt
13
+ //
14
+ // A *_FILE variable wins over its inline counterpart; without either, the
15
+ // built-in never-ending sci-fi story prompts are used.
16
+ import { readFile } from "node:fs/promises";
17
+
18
+ import type { ExtensionAPI, ExtensionContext, TurnEndEvent } from "@earendil-works/pi-coding-agent";
19
+
20
+ const demoEnabled = process.env["PI_DEMO_MODE"] === "1";
21
+
22
+ const fallbackInitialPrompt =
23
+ "You are narrating a never-ending sci-fi adventure. Continue in short paragraphs. Whenever the user sends a message, treat it as a live director note and incorporate it immediately. Never end the story.";
24
+ const fallbackFollowupPrompt = "Continue. Try to write as long as possible.";
25
+
26
+ const compactAtContextPercent = 70;
27
+ const demoCompactionInstructions = [
28
+ "Preserve the demo narrative state, named entities, current setting,",
29
+ "unresolved plot threads, and latest user direction.",
30
+ "Keep the summary concise so the story can continue after compaction."
31
+ ].join(" ");
32
+
33
+ async function promptText(inlineVar: string, fileVar: string, fallback: string): Promise<string> {
34
+ const file = process.env[fileVar];
35
+ if (file !== undefined && file !== "") {
36
+ try {
37
+ const text = (await readFile(file, "utf8")).trim();
38
+ if (text !== "") {
39
+ return text;
40
+ }
41
+ } catch {
42
+ // Fall through to the inline value or fallback below.
43
+ }
44
+ }
45
+ const inline = process.env[inlineVar];
46
+ return inline === undefined || inline === "" ? fallback : inline;
47
+ }
48
+
49
+ // Demo chrome: recordings only need the chat and the perf status line, so the
50
+ // footer is replaced with a single dim line (extension statuses + model name)
51
+ // and the input editor is hidden. The built-in footer's cwd and context lines
52
+ // are intentionally dropped.
53
+ type DemoFooterTheme = { fg(color: string, text: string): string };
54
+ type DemoFooterData = { getExtensionStatuses(): ReadonlyMap<string, string> };
55
+ type DemoFooterComponent = { render(width: number): string[] };
56
+ type DemoChromeUi = {
57
+ setFooter(
58
+ factory:
59
+ | ((tui: unknown, theme: DemoFooterTheme, footerData: DemoFooterData) => DemoFooterComponent)
60
+ | undefined
61
+ ): void;
62
+ setEditorComponent(
63
+ factory: ((tui: unknown, theme: unknown, keybindings: unknown) => unknown) | undefined
64
+ ): void;
65
+ };
66
+
67
+ const ansiPattern = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "gu");
68
+
69
+ function visibleLength(text: string): number {
70
+ return text.replace(ansiPattern, "").length;
71
+ }
72
+
73
+ function demoFooterLines(left: string, right: string, width: number): string[] {
74
+ const leftWidth = visibleLength(left);
75
+ const rightWidth = visibleLength(right);
76
+ if (left === "") {
77
+ return [" ".repeat(Math.max(0, width - rightWidth)) + right];
78
+ }
79
+ if (leftWidth + 2 + rightWidth <= width) {
80
+ return [left + " ".repeat(width - leftWidth - rightWidth) + right];
81
+ }
82
+ return [left, " ".repeat(Math.max(0, width - rightWidth)) + right];
83
+ }
84
+
85
+ function applyDemoChrome(ctx: ExtensionContext): void {
86
+ const ui = ctx.ui as unknown as Partial<DemoChromeUi>;
87
+ if (typeof ui.setFooter !== "function" || typeof ui.setEditorComponent !== "function") {
88
+ return;
89
+ }
90
+ const modelName = ctx.model?.id ?? "";
91
+ ui.setFooter((_tui, theme, footerData) => ({
92
+ render(width: number): string[] {
93
+ const left = Array.from(footerData.getExtensionStatuses().entries())
94
+ .sort(([a], [b]) => a.localeCompare(b))
95
+ .map(([, text]) => text.replace(/[\r\n\t]+/gu, " ").trim())
96
+ .filter((text) => text !== "")
97
+ .join(" ");
98
+ return demoFooterLines(left, theme.fg("dim", modelName), width);
99
+ }
100
+ }));
101
+ void hideEditor(ui as DemoChromeUi);
102
+ }
103
+
104
+ async function hideEditor(ui: DemoChromeUi): Promise<void> {
105
+ try {
106
+ const pkg = (await import("@earendil-works/pi-coding-agent")) as unknown as {
107
+ CustomEditor: new (
108
+ tui: unknown,
109
+ theme: unknown,
110
+ keybindings: unknown
111
+ ) => { render(width: number): string[] };
112
+ };
113
+ class HiddenEditor extends pkg.CustomEditor {
114
+ override render(): string[] {
115
+ return [];
116
+ }
117
+ }
118
+ ui.setEditorComponent((tui, theme, keybindings) => new HiddenEditor(tui, theme, keybindings));
119
+ } catch {
120
+ // Keep the default editor when the package cannot be imported (e.g. the
121
+ // extension is loaded outside Pi's module loader in tests).
122
+ }
123
+ }
124
+
125
+ export default function piDemoMode(pi: ExtensionAPI): void {
126
+ if (!demoEnabled) {
127
+ return;
128
+ }
129
+ let started = false;
130
+ let stopped = false;
131
+ let compacting = false;
132
+
133
+ function queueInitialPrompt(): void {
134
+ void promptText(
135
+ "PI_DEMO_INITIAL_PROMPT",
136
+ "PI_DEMO_INITIAL_PROMPT_FILE",
137
+ fallbackInitialPrompt
138
+ ).then((prompt) => {
139
+ if (!stopped) {
140
+ pi.sendUserMessage(prompt);
141
+ }
142
+ });
143
+ }
144
+
145
+ function queueFollowup(): void {
146
+ void promptText(
147
+ "PI_DEMO_FOLLOWUP_PROMPT",
148
+ "PI_DEMO_FOLLOWUP_PROMPT_FILE",
149
+ fallbackFollowupPrompt
150
+ ).then((prompt) => {
151
+ if (!stopped && !compacting) {
152
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
153
+ }
154
+ });
155
+ }
156
+
157
+ function compactThenFollowup(ctx: ExtensionContext): void {
158
+ if (compacting) {
159
+ return;
160
+ }
161
+ compacting = true;
162
+ ctx.compact({
163
+ customInstructions: demoCompactionInstructions,
164
+ onComplete: () => {
165
+ compacting = false;
166
+ queueFollowup();
167
+ },
168
+ onError: (error) => {
169
+ compacting = false;
170
+ stopped = true;
171
+ ctx.ui.notify("Demo compaction failed: " + error.message, "error");
172
+ }
173
+ });
174
+ }
175
+
176
+ pi.on("session_start", (event, ctx) => {
177
+ if (ctx.mode !== "tui") {
178
+ return;
179
+ }
180
+ applyDemoChrome(ctx);
181
+ if (started || stopped || event.reason !== "startup") {
182
+ return;
183
+ }
184
+ started = true;
185
+ queueInitialPrompt();
186
+ });
187
+
188
+ pi.on("turn_end", (event, ctx) => {
189
+ if (!started || stopped || compacting || ctx.mode !== "tui") {
190
+ return;
191
+ }
192
+ if (event.message.role !== "assistant") {
193
+ return;
194
+ }
195
+ switch (event.message.stopReason) {
196
+ case "aborted":
197
+ case "error":
198
+ stopped = true;
199
+ return;
200
+ case "toolUse":
201
+ return;
202
+ }
203
+ if (shouldCompactBeforeFollowup(event, ctx)) {
204
+ compactThenFollowup(ctx);
205
+ return;
206
+ }
207
+ queueFollowup();
208
+ });
209
+
210
+ pi.on("session_shutdown", () => {
211
+ stopped = true;
212
+ });
213
+ }
214
+
215
+ function shouldCompactBeforeFollowup(event: TurnEndEvent, ctx: ExtensionContext): boolean {
216
+ const contextPercent = currentContextPercent(event, ctx);
217
+ return contextPercent !== undefined && contextPercent >= compactAtContextPercent;
218
+ }
219
+
220
+ function currentContextPercent(event: TurnEndEvent, ctx: ExtensionContext): number | undefined {
221
+ const usage = ctx.getContextUsage();
222
+ if (usage?.percent !== undefined && usage.percent !== null) {
223
+ return usage.percent;
224
+ }
225
+ if (event.message.role !== "assistant") {
226
+ return undefined;
227
+ }
228
+ const contextWindow = ctx.model?.contextWindow;
229
+ if (contextWindow === undefined || contextWindow <= 0) {
230
+ return undefined;
231
+ }
232
+ return (event.message.usage.totalTokens / contextWindow) * 100;
233
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "pi-demo-mode",
3
+ "version": "0.1.0",
4
+ "description": "Self-driving demo mode for Pi: continuous story generation with a stripped-down TUI",
5
+ "keywords": [
6
+ "pi-package",
7
+ "demo",
8
+ "extension"
9
+ ],
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/osolmaz/pi-demo-mode.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/osolmaz/pi-demo-mode/issues"
18
+ },
19
+ "homepage": "https://github.com/osolmaz/pi-demo-mode#readme",
20
+ "files": [
21
+ "extensions/**",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./extensions"
28
+ ]
29
+ },
30
+ "scripts": {
31
+ "format": "prettier --check .",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run",
34
+ "check": "npm run format && npm run typecheck && npm test"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "^0.82.1",
41
+ "@types/node": "^22.0.0",
42
+ "prettier": "^3.0.0",
43
+ "typescript": "^5.0.0",
44
+ "vitest": "^3.0.0"
45
+ }
46
+ }