narraeon 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.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +164 -0
  3. package/README.zh-CN.md +164 -0
  4. package/dist/node/cli/NarraeonCli.js +163 -0
  5. package/dist/node/cli/main.js +38 -0
  6. package/dist/node/protocol/appPreferences.js +5 -0
  7. package/dist/node/protocol/contentTree.js +14 -0
  8. package/dist/node/protocol/modelConnections.js +110 -0
  9. package/dist/node/protocol/playerViews.js +1 -0
  10. package/dist/node/protocol/v1.js +278 -0
  11. package/dist/node/runtime/V1Runtime.js +625 -0
  12. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
  14. package/dist/node/runtime/config/appPaths.js +19 -0
  15. package/dist/node/runtime/content/ContentLimits.js +1 -0
  16. package/dist/node/runtime/content/ContentTreeFile.js +1 -0
  17. package/dist/node/runtime/content/ContentWorkspace.js +601 -0
  18. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
  19. package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
  20. package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
  21. package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
  22. package/dist/node/runtime/model/AiFailureLog.js +332 -0
  23. package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
  24. package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
  25. package/dist/node/runtime/model/ModelHost.js +89 -0
  26. package/dist/node/runtime/model/ModelHostStream.js +367 -0
  27. package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
  28. package/dist/node/runtime/model/ProviderStream.js +90 -0
  29. package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
  30. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
  31. package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
  32. package/dist/node/runtime/play/PlayCallChain.js +1280 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
  36. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
  37. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
  38. package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
  39. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
  41. package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
  42. package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
  43. package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
  44. package/dist/node/runtime/world/WorldStateRevision.js +75 -0
  45. package/dist/node/server/createRuntime.js +6 -0
  46. package/dist/node/server/createServer.js +218 -0
  47. package/dist/node/server/main.js +25 -0
  48. package/dist/node/server/startWebServer.js +28 -0
  49. package/dist/node/shared/default-play-prompts.js +85 -0
  50. package/dist/node/shared/default-preset-host.js +538 -0
  51. package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
  52. package/dist/node/shared/first-party-action-choices.js +164 -0
  53. package/dist/node/shared/first-party-generic-panels.js +105 -0
  54. package/dist/node/shared/first-party-play-preset-templates.js +41 -0
  55. package/dist/node/shared/first-party-player-view.js +179 -0
  56. package/dist/node/shared/play-preset-prompt-roles.js +4 -0
  57. package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
  58. package/dist/web/assets/index-DDx1itzT.js +1192 -0
  59. package/dist/web/index.html +14 -0
  60. package/package.json +86 -0
@@ -0,0 +1,218 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { PassThrough } from "node:stream";
4
+ import fastifyStatic from "@fastify/static";
5
+ import Fastify, { LogController, } from "fastify";
6
+ import { maxPortableContentArchiveBase64Characters } from "../protocol/contentTree.js";
7
+ import { parseV1Envelope, V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
8
+ const runtimeRequestBodyLimit = maxPortableContentArchiveBase64Characters + 64 * 1024;
9
+ const playCallChainStreamContentType = "application/x-ndjson";
10
+ class RuntimeLogController extends LogController {
11
+ incomingRequest(request, reply, metadata) {
12
+ // The request body is not parsed yet. Runtime API requests are logged by
13
+ // the route pre-handler once their request type is known.
14
+ if (isRuntimeApiRequest(request))
15
+ return;
16
+ super.incomingRequest(request, reply, metadata);
17
+ }
18
+ requestCompleted(error, request, reply, metadata) {
19
+ if (isPollingRequest(request) && error == null && reply.statusCode < 400)
20
+ return;
21
+ super.requestCompleted(error, request, reply, metadata);
22
+ }
23
+ }
24
+ function isPollingRequest(request) {
25
+ const body = request.body;
26
+ if (!isRuntimeApiRequest(request) || !isRecord(body))
27
+ return false;
28
+ const runtimeRequest = body.request;
29
+ return (isRecord(runtimeRequest) &&
30
+ (runtimeRequest.type === "setting-improvement.progress" ||
31
+ runtimeRequest.type === "play.chain.inspect"));
32
+ }
33
+ function isRuntimeApiRequest(request) {
34
+ return request.url === "/api/runtime/v1";
35
+ }
36
+ function isRecord(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+ export async function createServer(input) {
40
+ if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65_535)
41
+ throw new Error(`Invalid local web port: ${input.port}`);
42
+ const server = Fastify({
43
+ logController: new RuntimeLogController(),
44
+ logger: input.logger === false
45
+ ? false
46
+ : {
47
+ level: "info",
48
+ redact: {
49
+ paths: [
50
+ "req.headers.authorization",
51
+ "req.headers.x-api-key",
52
+ "req.body",
53
+ ],
54
+ censor: "[REDACTED]",
55
+ },
56
+ ...(input.logStream === undefined
57
+ ? {}
58
+ : { stream: input.logStream }),
59
+ },
60
+ });
61
+ const suffix = input.port === 80 ? "" : `:${input.port}`;
62
+ const origins = new Set([
63
+ `http://127.0.0.1${suffix}`,
64
+ `http://localhost${suffix}`,
65
+ ]);
66
+ const html = await readFile(join(input.staticRoot, "index.html"), "utf8");
67
+ // Origin is what actually stops a hostile page from driving this server: a
68
+ // cross-site request carries its own origin, and a JSON POST always triggers
69
+ // a preflight, so the header is always there to check. A per-boot token on
70
+ // top bought nothing against that threat and made every restart invalidate
71
+ // the tab the user already had open.
72
+ const sameOrigin = (request, reply, done) => {
73
+ if (request.headers.origin === undefined ||
74
+ !origins.has(request.headers.origin))
75
+ void forbidden(reply);
76
+ else
77
+ done();
78
+ };
79
+ const logRuntimeRequest = (request, _reply, done) => {
80
+ if (!isPollingRequest(request))
81
+ request.log.info({ req: request }, "incoming request");
82
+ done();
83
+ };
84
+ server.addHook("onSend", (request, reply, payload, done) => {
85
+ void reply
86
+ .header("Cache-Control", request.url.startsWith("/api/") ? "no-store" : "no-cache")
87
+ .header("Content-Security-Policy",
88
+ // App renderer code is explicitly frozen local trusted code and runs
89
+ // inside its own sandboxed iframe. The inline allowance is required
90
+ // for srcDoc templates; the host still exposes no Runtime capability
91
+ // to that code and the product does not treat it as a hostile sandbox.
92
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'")
93
+ .header("X-Content-Type-Options", "nosniff")
94
+ .header("X-Frame-Options", "DENY");
95
+ done(null, payload);
96
+ });
97
+ server.get("/health", () => ({
98
+ status: "ok",
99
+ protocol: "narraeon.runtime/v1",
100
+ }));
101
+ server.post("/api/runtime/v1", {
102
+ bodyLimit: runtimeRequestBodyLimit,
103
+ onRequest: [sameOrigin],
104
+ preHandler: [logRuntimeRequest],
105
+ }, async (request, reply) => {
106
+ let runtimeRequestType;
107
+ try {
108
+ const envelope = parseV1Envelope(request.body);
109
+ runtimeRequestType = envelope.request.type;
110
+ if (acceptsPlayCallChainStream(request) &&
111
+ (envelope.request.type === "play.chain.start" ||
112
+ envelope.request.type === "play.chain.append"))
113
+ return sendPlayCallChainStream(input.runtime, envelope.request, request, reply);
114
+ const response = await input.runtime.handle(envelope.request);
115
+ if ((runtimeRequestType === "play.chain.start" ||
116
+ runtimeRequestType === "play.chain.append") &&
117
+ isRecord(response.result) &&
118
+ response.result.status === "interrupted" &&
119
+ typeof response.result.lastFailure === "string")
120
+ request.log.error({
121
+ runtimeRequestType,
122
+ playCallFailure: response.result.lastFailure,
123
+ }, "play call interrupted");
124
+ return reply.send(response);
125
+ }
126
+ catch (error) {
127
+ if (error instanceof V1ProtocolError) {
128
+ return reply.status(400).send({
129
+ protocol: "narraeon.runtime/v1",
130
+ error: {
131
+ code: error.code,
132
+ message: error.message,
133
+ },
134
+ });
135
+ }
136
+ throw error;
137
+ }
138
+ });
139
+ await server.register(fastifyStatic, {
140
+ root: input.staticRoot,
141
+ index: false,
142
+ });
143
+ const index = (_request, reply) => reply.type("text/html; charset=utf-8").send(html);
144
+ server.get("/", index);
145
+ server.get("/index.html", index);
146
+ return server;
147
+ }
148
+ function acceptsPlayCallChainStream(request) {
149
+ const accept = request.headers.accept;
150
+ return (typeof accept === "string" &&
151
+ accept
152
+ .split(",")
153
+ .some((value) => value.trim().startsWith(playCallChainStreamContentType)));
154
+ }
155
+ function sendPlayCallChainStream(runtime, runtimeRequest, request, reply) {
156
+ const stream = new PassThrough();
157
+ const send = (frame) => {
158
+ if (stream.destroyed || stream.writableEnded)
159
+ return;
160
+ stream.write(`${JSON.stringify({ protocol: v1Protocol, frame })}\n`);
161
+ };
162
+ const observer = {
163
+ onSnapshot(value) {
164
+ send({ kind: "snapshot", value, final: false });
165
+ },
166
+ onAssistantDelta(delta) {
167
+ send({
168
+ kind: "assistant_delta",
169
+ eventId: delta.eventId,
170
+ deltaKind: delta.kind,
171
+ text: delta.text,
172
+ updatedAt: delta.updatedAt,
173
+ });
174
+ },
175
+ };
176
+ void runtime
177
+ .handle(runtimeRequest, observer)
178
+ .then((response) => {
179
+ if (!isPlayCallChainView(response.result))
180
+ throw new Error("The Runtime stream did not return a call-chain snapshot");
181
+ send({ kind: "snapshot", value: response.result, final: true });
182
+ if (response.result.status === "interrupted" &&
183
+ response.result.lastFailure !== null)
184
+ request.log.error({
185
+ runtimeRequestType: runtimeRequest.type,
186
+ playCallFailure: response.result.lastFailure,
187
+ }, "play call interrupted");
188
+ })
189
+ .catch((error) => {
190
+ const message = error instanceof Error ? error.message : "Runtime streaming failed";
191
+ request.log.error({ err: error, runtimeRequestType: runtimeRequest.type }, "play call stream failed");
192
+ send({ kind: "error", message });
193
+ })
194
+ .finally(() => {
195
+ if (!stream.destroyed && !stream.writableEnded)
196
+ stream.end();
197
+ });
198
+ return reply.type(playCallChainStreamContentType).send(stream);
199
+ }
200
+ function isPlayCallChainView(value) {
201
+ return (isRecord(value) &&
202
+ typeof value.chainId === "string" &&
203
+ typeof value.worldId === "string" &&
204
+ Array.isArray(value.previousContexts) &&
205
+ Array.isArray(value.events) &&
206
+ (value.status === "ready" ||
207
+ value.status === "running" ||
208
+ value.status === "interrupted"));
209
+ }
210
+ function forbidden(reply) {
211
+ return reply.status(403).send({
212
+ protocol: "narraeon.runtime/v1",
213
+ error: {
214
+ code: "forbidden_request",
215
+ message: "Local web request is not authorized",
216
+ },
217
+ });
218
+ }
@@ -0,0 +1,25 @@
1
+ import { resolve } from "node:path";
2
+ import { resolveAppPaths } from "../runtime/config/appPaths.js";
3
+ import { parseWebPort, startWebServer } from "./startWebServer.js";
4
+ const port = parseWebPort(process.env.NARRAEON_PORT);
5
+ const paths = resolveAppPaths(process.env);
6
+ const server = await startWebServer({
7
+ paths,
8
+ staticRoot: resolve("dist/web"),
9
+ port,
10
+ });
11
+ installGracefulShutdown(server);
12
+ function installGracefulShutdown(server) {
13
+ let closing = false;
14
+ const close = () => {
15
+ if (closing)
16
+ return;
17
+ closing = true;
18
+ void server.close().catch((error) => {
19
+ console.error(error);
20
+ process.exitCode = 1;
21
+ });
22
+ };
23
+ process.once("SIGINT", close);
24
+ process.once("SIGTERM", close);
25
+ }
@@ -0,0 +1,28 @@
1
+ import { createRuntime } from "./createRuntime.js";
2
+ import { createServer } from "./createServer.js";
3
+ export const defaultWebPort = 4317;
4
+ export async function startWebServer(input) {
5
+ const runtime = await createRuntime(input.paths);
6
+ const server = await createServer({
7
+ runtime,
8
+ staticRoot: input.staticRoot,
9
+ port: input.port,
10
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
11
+ });
12
+ try {
13
+ await server.listen({ host: "127.0.0.1", port: input.port });
14
+ return server;
15
+ }
16
+ catch (error) {
17
+ await server.close().catch(() => undefined);
18
+ throw error;
19
+ }
20
+ }
21
+ export function parseWebPort(value, source = "NARRAEON_PORT") {
22
+ if (value === undefined)
23
+ return defaultWebPort;
24
+ const parsed = Number(value);
25
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535)
26
+ throw new Error(`${source} is not a valid port: ${value}`);
27
+ return parsed;
28
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Canonical author-owned narrative prompt shared by the default and
3
+ * first-party presets. Portable presets still receive ordinary editable file
4
+ * contents; this module only prevents the shipped starting points from
5
+ * drifting apart. The general adjudication and state-maintenance criteria live
6
+ * in the host preset, which every mode loads.
7
+ */
8
+ export const defaultNarrationPromptZhCN = `# 玩家可见叙事规则
9
+
10
+ 当你决定输出玩家可见故事正文时,写成互动式小说,不写成裁决报告、工具日志或主持流程说明。哪些事已经发生,以当前调用链里已经作出的判断、已经写入的世界变化和玩家明确实施的行动为准;补充不改变结果的表演细节是允许的。
11
+
12
+ 这一段是互动式小说的正文,不是过程报告。读者是玩家本人,他要的是读下去的体验,不是一份“这次交互发生了什么”的交代。
13
+
14
+ ## 怎么写
15
+
16
+ 从玩家这次输入写起,把它扩写成完整的场景——他具体怎么做的、什么语气神态、当时的姿态和距离——再接着写它引出的后续。不要用“你说要去找他”这类回指句开头,直接把那个动作演出来。
17
+
18
+ 让环境、光线、声音、他人的小动作和身体反应参与进来。情绪和关系尽量侧写,通过动作走形、话说到一半停住、视线躲闪来呈现,而不是直接判定“他很紧张”。玩家没有互动的 NPC 也在做自己的事,让场面里同时发生的事不必都与玩家有关。
19
+
20
+ 当前调用链写进世界文档、而玩家角色感知得到的变化,这一段必须演出来:不是复述“他的态度变好了”,是让他做出或说出能看出这一点的那句话、那个动作。写进了文档却没进叙事,玩家就读不到它,对他等于没有发生。已经确定的结果同样不能改:谁在哪、拿着什么、答应了什么,都要保持一致。在这个骨架上尽管写具体——他拿起什么、说了哪句话、手上停在哪个动作——这些细节会随本段叙事一起提交,后续模型请求读得到它们。
21
+
22
+ 以玩家原文、已加载的世界材料和当前调用链已经确定的结果为准。结果既包括写进文档的持续变化,也包括只在当前场面成立的直接后果;两者都不得被叙事掩盖或改写。玩家只表达愿望、意图或尝试时,不要擅自把愿望写成已经达成。
23
+
24
+ ## 怎么收尾
25
+
26
+ **最后一句写某个人做的一件具体的事,或说的一句具体的话。** 他问了什么,原话写出来;他把东西递过来,写他怎么递的;他站起身往门口走,写他走了。这句话本身要有内容,读者读的是这件事,不是读“该我了”。
27
+
28
+ 轮到玩家,是他从这件事里自己得出的结论,不需要你告诉他。所以结尾不写场面的状态,只写事件:
29
+
30
+ - 不写“没人接话”“屋里安静下来”“空气凝住了”——沉默不是事件,用它收尾等于什么都没发生。
31
+ - 不写“两道视线落回你身上”“他等着你的下文”“所有人都在等你开口”——这是把“轮到你了”翻译成了场面语言,和直接问“你打算怎么做”是同一件事,只是穿了小说的皮。
32
+ - 不写“你打算怎么做”“接下来你会怎么办”“你可以选择……”,也不要在结尾把局势总结一遍或列出他有哪些选项。
33
+
34
+ 如果写到最后发现场上确实没人有动作可做,那说明这一段收早了:让某个人先做点什么——开口、起身、动手、走近——再停。
35
+
36
+ ## 边界
37
+
38
+ 玩家可见文本只写小说正文,可以分多个自然段;不要标题、清单、解释、工具过程或幕后说明。停笔位置约束的是故事推进到哪里,不是可以写多厚——在这个范围内把场面、反应和氛围写足。
39
+
40
+ 篇幅由这一刻的分量决定,不是每段都写成同一个长度。过场收快;玩家真正在意的那一刻停下来,把那几秒拆开写。
41
+
42
+ 不要替玩家作出**本该由他决定**的事——改变目标立场、答应或拒绝、交出或收下、动手或收手。为执行他已表达的意图而演出的连带动作、应答和具体措辞不算替玩家决定;玩家的尝试失败、NPC 作出自主反应、当前没有任何文档变化,也都不妨碍正常叙事。
43
+ `;
44
+ export const defaultNarrationPromptEn = `# Player-visible narrative rules
45
+
46
+ Whenever you produce player-visible story text, write it as an interactive novel, not as an adjudication report, tool log, or explanation of the hosting process. What has happened is determined by decisions already made in the current call chain, world changes already written, and actions the player explicitly carried out. You may add performance details that do not alter those outcomes.
47
+
48
+ This is the body of an interactive novel, not a process report. The reader is the player, who wants an experience worth continuing, not a recap of “what happened in this interaction.”
49
+
50
+ ## How to write
51
+
52
+ Begin with the player's current input. Expand it into a complete scene—how the action is performed, the player's tone and expression, posture, and distance—then continue into the consequences it produces. Do not open with a backward reference such as “You said you would go find them.” Put the action directly on the page.
53
+
54
+ Bring in the environment, light, sound, other characters' small movements, and physical reactions. Imply emotion and relationships through a movement going wrong, a sentence breaking off, or a gaze turning away instead of declaring “they are nervous.” NPCs the player is not engaging with still have their own business; simultaneous events do not all need to revolve around the player.
55
+
56
+ Any change written to a world document in the current call chain that the player character can perceive must be dramatized here. Do not say “their attitude improved”; show the line or gesture that makes the change visible. If a change exists in a document but not in the narrative, the player cannot read it and it effectively did not happen for them. Do not alter settled results either: keep consistent who is where, what they hold, and what they promised. Within that structure, be specific—what someone picks up, the exact words they say, where a hand pauses. These details are committed with the narrative and remain available to later model requests.
57
+
58
+ Follow the player's original text, the loaded world material, and outcomes already settled in the current call chain. Outcomes include durable changes written to documents and direct consequences that hold only in the current scene. The narrative may neither hide nor rewrite either kind. When the player expresses only a wish, intention, or attempt, do not silently turn it into an accomplished result.
59
+
60
+ ## How to end
61
+
62
+ **Make the final sentence a specific action someone takes or a specific line they say.** If someone asks a question, write the actual question. If they hand something over, show how. If they stand and walk toward the door, write that movement. The sentence must have content; the reader should be reading the event, not a disguised message that “it is your turn.”
63
+
64
+ The player can infer that it is their turn. Do not state the condition of the scene at the end; end on an event:
65
+
66
+ - Do not end with “no one answers,” “the room falls quiet,” or “the air freezes.” Silence is not an event, and ending on it means nothing happened.
67
+ - Do not end with “two pairs of eyes return to you,” “they wait for you to continue,” or “everyone waits for you to speak.” Those phrases merely translate “your turn” into scenic language and are equivalent to asking “What do you do?”, dressed in prose.
68
+ - Do not write “What do you do?”, “What happens next?”, or “You could choose…”. Do not summarize the situation or list the player's options at the end.
69
+
70
+ If you reach the end and nobody in the scene has an action to take, the passage stopped too early. Let someone do something—speak, stand, act, or approach—then stop.
71
+
72
+ ## Boundaries
73
+
74
+ Player-visible text contains only the novel's prose and may use several natural paragraphs. Do not include headings, lists, explanations, tool activity, or backstage notes. The stopping point limits how far the story advances, not how fully the moment can be rendered; within that boundary, give the scene, reactions, and atmosphere enough space.
75
+
76
+ Let the weight of the moment determine length instead of forcing every passage into the same size. Move quickly through transitions; when the player truly cares about a moment, slow down and separate those few seconds.
77
+
78
+ Do not make a decision that **belongs to the player**—changing goals or allegiance, accepting or refusing, giving or taking something, striking or holding back. Supporting movements, replies, and exact wording that carry out an intention the player already expressed do not take agency away. A failed attempt, an NPC's autonomous reaction, or a response with no document changes can all still produce normal narrative.
79
+ `;
80
+ export const defaultNarrationPrompt = defaultNarrationPromptEn;
81
+ export function defaultNarrationPromptForLocale(locale) {
82
+ return locale === "zh-CN"
83
+ ? defaultNarrationPromptZhCN
84
+ : defaultNarrationPromptEn;
85
+ }