mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
feat(core): add narrative forecast agent, prompts, renderers and runner
RFC #342 v1 的三个操作 create / get / select: - forecast/prompts.ts:zh/en 双语提示词(系统/用户/修复三个 builder), 向模型明确要求互斥分支、只输出 JSON、字段结构与合法枚举值 - forecast/agent.ts:NarrativeForecastAgent 继承 BaseAgent,单次调用 + 一次带校验错误反馈的重试;分支数量与请求不符也算非法输出;两次都 失败直接抛错,绝不把非法输出往下传 - forecast/render.ts:从 forecast.json 确定性渲染 comparison.md(对比表 + 分支详情)与 selected-branch-plan.md(含过期警告与"不修改正史"脚注), 不需要第二次 LLM 调用 - forecast/runner.ts: - create:读上下文 → 生成分支 → 赋 branch-N id → schema 校验通过后才写 story/runtime/narrative-forecasts/<id>/,模型输出非法时不留任何文件 - get:重算 contextFingerprint,正史变化后把 status 持久化标记为 stale - select:只写 selected-branch-plan.md(不改 forecast.json、不碰正史), 选不存在的分支报错并列出可选分支 - index.ts:导出 forecast 模块公共 API - 测试:agent 校验重试路径、create/get/select 全流程、兄弟分支隔离、 正史文件前后快照一致、stale 标记、非法输出零残留、越界参数先于模型 调用被拒绝
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { NarrativeForecastAgent } from "../forecast/agent.js";
|
||||
import { makeForecastBranch } from "./helpers/forecast-fixture.js";
|
||||
import type { LLMMessage, LLMResponse } from "../llm/provider.js";
|
||||
|
||||
function llmResponse(content: string): LLMResponse {
|
||||
return { content, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } };
|
||||
}
|
||||
|
||||
function validModelJson(count: number): string {
|
||||
const branches = Array.from({ length: count }, (_, index) => {
|
||||
const { branchId: _branchId, ...rest } = makeForecastBranch({ title: `分支${index + 1}` });
|
||||
return rest;
|
||||
});
|
||||
return JSON.stringify({ branches });
|
||||
}
|
||||
|
||||
function makeAgent(): NarrativeForecastAgent {
|
||||
return new NarrativeForecastAgent({
|
||||
client: { provider: "openai" } as never,
|
||||
model: "fake",
|
||||
projectRoot: "/tmp",
|
||||
});
|
||||
}
|
||||
|
||||
function spyOnChat(responses: ReadonlyArray<string>) {
|
||||
const spy = vi.spyOn(
|
||||
NarrativeForecastAgent.prototype as unknown as { chat: (messages: ReadonlyArray<LLMMessage>) => Promise<LLMResponse> },
|
||||
"chat",
|
||||
);
|
||||
for (const content of responses) {
|
||||
spy.mockResolvedValueOnce(llmResponse(content));
|
||||
}
|
||||
return spy;
|
||||
}
|
||||
|
||||
const INPUT = {
|
||||
contextMarkdown: "# 正史上下文",
|
||||
divergence: "主角是否接受提议",
|
||||
branchCount: 2,
|
||||
horizon: 5,
|
||||
baseChapter: 12,
|
||||
language: "zh" as const,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("NarrativeForecastAgent", () => {
|
||||
it("returns validated branches from a valid first response", async () => {
|
||||
const spy = spyOnChat([validModelJson(2)]);
|
||||
|
||||
const output = await makeAgent().generateBranches(INPUT);
|
||||
|
||||
expect(output.branches).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [messages] = spy.mock.calls[0]!;
|
||||
expect(messages[0]?.role).toBe("system");
|
||||
expect(messages[1]?.content).toContain("主角是否接受提议");
|
||||
});
|
||||
|
||||
it("retries once with the validation error when the first response is invalid", async () => {
|
||||
const spy = spyOnChat(["这不是 JSON", validModelJson(2)]);
|
||||
|
||||
const output = await makeAgent().generateBranches(INPUT);
|
||||
|
||||
expect(output.branches).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
const [retryMessages] = spy.mock.calls[1]!;
|
||||
expect(retryMessages.at(-2)?.role).toBe("assistant");
|
||||
expect(retryMessages.at(-1)?.content).toContain("not valid JSON");
|
||||
});
|
||||
|
||||
it("throws after two invalid responses without further retries", async () => {
|
||||
const spy = spyOnChat(["垃圾输出一", "垃圾输出二"]);
|
||||
|
||||
await expect(makeAgent().generateBranches(INPUT)).rejects.toThrow(/not valid JSON/);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("treats a branch count mismatch as invalid output", async () => {
|
||||
const spy = spyOnChat([validModelJson(3), validModelJson(2)]);
|
||||
|
||||
const output = await makeAgent().generateBranches(INPUT);
|
||||
|
||||
expect(output.branches).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
const [retryMessages] = spy.mock.calls[1]!;
|
||||
expect(retryMessages.at(-1)?.content).toContain("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { NarrativeForecastAgent } from "../forecast/agent.js";
|
||||
import {
|
||||
createNarrativeForecast,
|
||||
getNarrativeForecast,
|
||||
selectNarrativeBranch,
|
||||
} from "../forecast/runner.js";
|
||||
import type { AgentContext } from "../agents/base.js";
|
||||
import {
|
||||
makeModelBranch,
|
||||
snapshotCanonicalFiles,
|
||||
writeForecastFixtureBook,
|
||||
} from "./helpers/forecast-fixture.js";
|
||||
|
||||
const BOOK_ID = "demo-book";
|
||||
const FIXED_NOW = () => new Date("2026-07-15T00:00:00Z");
|
||||
const FIXED_ID = "fc-20260715-000000";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runtime(projectRoot: string): AgentContext {
|
||||
return { client: { provider: "openai" } as never, model: "fake", projectRoot };
|
||||
}
|
||||
|
||||
function stubBranches() {
|
||||
return [
|
||||
makeModelBranch({ title: "接受提议" }),
|
||||
makeModelBranch({
|
||||
title: "拒绝提议",
|
||||
premise: "假设主角当场拒绝并公开把柄。",
|
||||
projectedChanges: {
|
||||
characters: ["主角声望上升"],
|
||||
relationships: ["与盟友结盟加深"],
|
||||
world: ["对手提前动手"],
|
||||
hooks: ["hook-03 保持休眠"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe("narrative forecast runner", () => {
|
||||
let root: string;
|
||||
let bookDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "inkos-forecast-run-"));
|
||||
bookDir = join(root, "books", BOOK_ID);
|
||||
await writeForecastFixtureBook(bookDir);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function stubAgent() {
|
||||
return vi.spyOn(NarrativeForecastAgent.prototype, "generateBranches")
|
||||
.mockResolvedValue({ branches: stubBranches() });
|
||||
}
|
||||
|
||||
function createOptions() {
|
||||
return {
|
||||
projectRoot: root,
|
||||
bookId: BOOK_ID,
|
||||
divergence: "主角是否接受对手的合作提议",
|
||||
branchCount: 2,
|
||||
horizon: 5,
|
||||
runtime: runtime(root),
|
||||
determinism: { now: FIXED_NOW },
|
||||
};
|
||||
}
|
||||
|
||||
it("creates forecast.json and comparison.md with assigned branch ids", async () => {
|
||||
const spy = stubAgent();
|
||||
|
||||
const result = await createNarrativeForecast(createOptions());
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(result.forecast.forecastId).toBe(FIXED_ID);
|
||||
expect(result.forecast.baseChapter).toBe(2);
|
||||
expect(result.forecast.status).toBe("active");
|
||||
expect(result.forecast.branches.map((branch) => branch.branchId)).toEqual(["branch-1", "branch-2"]);
|
||||
expect(result.forecast.createdAt).toBe("2026-07-15T00:00:00.000Z");
|
||||
|
||||
const onDisk = JSON.parse(await readFile(result.forecastJsonPath, "utf-8"));
|
||||
expect(onDisk.contextFingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||
const comparison = await readFile(result.comparisonPath, "utf-8");
|
||||
expect(comparison).toContain("接受提议");
|
||||
expect(comparison).toContain("拒绝提议");
|
||||
});
|
||||
|
||||
it("keeps sibling branches isolated in the stored forecast", async () => {
|
||||
stubAgent();
|
||||
|
||||
const result = await createNarrativeForecast(createOptions());
|
||||
|
||||
const [first, second] = result.forecast.branches;
|
||||
expect(first?.projectedChanges.relationships).toEqual(["主角与盟友决裂"]);
|
||||
expect(second?.projectedChanges.relationships).toEqual(["与盟友结盟加深"]);
|
||||
expect(first?.beats).not.toBe(second?.beats);
|
||||
});
|
||||
|
||||
it("does not modify any canonical file when creating a forecast", async () => {
|
||||
stubAgent();
|
||||
const before = await snapshotCanonicalFiles(bookDir);
|
||||
|
||||
await createNarrativeForecast(createOptions());
|
||||
|
||||
expect(await snapshotCanonicalFiles(bookDir)).toEqual(before);
|
||||
});
|
||||
|
||||
it("leaves no forecast files behind when the model output is invalid", async () => {
|
||||
const chatSpy = vi.spyOn(
|
||||
NarrativeForecastAgent.prototype as unknown as { chat: () => Promise<{ content: string; usage: object }> },
|
||||
"chat",
|
||||
).mockResolvedValue({ content: "不是 JSON", usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } });
|
||||
|
||||
await expect(createNarrativeForecast(createOptions())).rejects.toThrow(/not valid JSON/);
|
||||
|
||||
expect(chatSpy).toHaveBeenCalledTimes(2);
|
||||
expect(await exists(join(bookDir, "story", "runtime", "narrative-forecasts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects out-of-range branch counts and horizons before calling the model", async () => {
|
||||
const spy = stubAgent();
|
||||
|
||||
await expect(createNarrativeForecast({ ...createOptions(), branchCount: 1 })).rejects.toThrow(/branchCount/);
|
||||
await expect(createNarrativeForecast({ ...createOptions(), horizon: 0 })).rejects.toThrow(/horizon/);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a fresh forecast as active", async () => {
|
||||
stubAgent();
|
||||
await createNarrativeForecast(createOptions());
|
||||
|
||||
const result = await getNarrativeForecast({ projectRoot: root, bookId: BOOK_ID, forecastId: FIXED_ID });
|
||||
|
||||
expect(result.stale).toBe(false);
|
||||
expect(result.forecast.status).toBe("active");
|
||||
});
|
||||
|
||||
it("marks a forecast stale after the canonical context changes", async () => {
|
||||
stubAgent();
|
||||
await createNarrativeForecast(createOptions());
|
||||
await writeFile(join(bookDir, "story", "state", "current_state.json"), JSON.stringify({ facts: ["主角离开东城"] }), "utf-8");
|
||||
|
||||
const result = await getNarrativeForecast({ projectRoot: root, bookId: BOOK_ID, forecastId: FIXED_ID });
|
||||
|
||||
expect(result.stale).toBe(true);
|
||||
const onDisk = JSON.parse(await readFile(result.forecastJsonPath, "utf-8"));
|
||||
expect(onDisk.status).toBe("stale");
|
||||
});
|
||||
|
||||
it("selects a branch by writing only selected-branch-plan.md", async () => {
|
||||
stubAgent();
|
||||
await createNarrativeForecast(createOptions());
|
||||
const forecastJsonPath = join(bookDir, "story", "runtime", "narrative-forecasts", FIXED_ID, "forecast.json");
|
||||
const forecastJsonBefore = await readFile(forecastJsonPath, "utf-8");
|
||||
const canonBefore = await snapshotCanonicalFiles(bookDir);
|
||||
|
||||
const result = await selectNarrativeBranch({
|
||||
projectRoot: root,
|
||||
bookId: BOOK_ID,
|
||||
forecastId: FIXED_ID,
|
||||
branchId: "branch-2",
|
||||
determinism: { now: FIXED_NOW },
|
||||
});
|
||||
|
||||
expect(result.branch.branchId).toBe("branch-2");
|
||||
const plan = await readFile(result.planPath, "utf-8");
|
||||
expect(plan).toContain("拒绝提议");
|
||||
expect(plan).not.toContain("branch-1");
|
||||
expect(await readFile(forecastJsonPath, "utf-8")).toBe(forecastJsonBefore);
|
||||
expect(await snapshotCanonicalFiles(bookDir)).toEqual(canonBefore);
|
||||
});
|
||||
|
||||
it("refuses to select a branch that does not exist", async () => {
|
||||
stubAgent();
|
||||
await createNarrativeForecast(createOptions());
|
||||
|
||||
await expect(selectNarrativeBranch({
|
||||
projectRoot: root,
|
||||
bookId: BOOK_ID,
|
||||
forecastId: FIXED_ID,
|
||||
branchId: "branch-9",
|
||||
})).rejects.toThrow(/branch-9[\s\S]*branch-1, branch-2/);
|
||||
|
||||
expect(await exists(join(
|
||||
bookDir, "story", "runtime", "narrative-forecasts", FIXED_ID, "selected-branch-plan.md",
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("warns in the plan when selecting from a stale forecast", async () => {
|
||||
stubAgent();
|
||||
await createNarrativeForecast(createOptions());
|
||||
await writeFile(join(bookDir, "chapters", "0003_反击.md"), "第三章正文", "utf-8");
|
||||
|
||||
const result = await selectNarrativeBranch({
|
||||
projectRoot: root,
|
||||
bookId: BOOK_ID,
|
||||
forecastId: FIXED_ID,
|
||||
branchId: "branch-1",
|
||||
determinism: { now: FIXED_NOW },
|
||||
});
|
||||
|
||||
expect(result.stale).toBe(true);
|
||||
expect(await readFile(result.planPath, "utf-8")).toContain("已过期");
|
||||
});
|
||||
|
||||
it("errors early when the book does not exist", async () => {
|
||||
await mkdir(join(root, "books"), { recursive: true });
|
||||
await expect(createNarrativeForecast({ ...createOptions(), bookId: "nope" })).rejects.toThrow(/nope/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ForecastBranch, NarrativeForecast } from "../../forecast/schema.js";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import type { ForecastBranch, ForecastModelBranch, NarrativeForecast } from "../../forecast/schema.js";
|
||||
|
||||
export function makeForecastBranch(overrides: Partial<ForecastBranch> = {}): ForecastBranch {
|
||||
return {
|
||||
@@ -27,6 +29,53 @@ export function makeForecastBranch(overrides: Partial<ForecastBranch> = {}): For
|
||||
};
|
||||
}
|
||||
|
||||
export function makeModelBranch(overrides: Partial<ForecastBranch> = {}): ForecastModelBranch {
|
||||
const { branchId: _branchId, ...rest } = makeForecastBranch(overrides);
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Minimal canonical book on disk for forecast runner tests. */
|
||||
export async function writeForecastFixtureBook(bookDir: string): Promise<void> {
|
||||
await mkdir(join(bookDir, "chapters"), { recursive: true });
|
||||
await mkdir(join(bookDir, "story", "state"), { recursive: true });
|
||||
await mkdir(join(bookDir, "story", "outline"), { recursive: true });
|
||||
|
||||
await writeFile(join(bookDir, "book.json"), JSON.stringify({ id: "demo-book", title: "示例书", language: "zh" }), "utf-8");
|
||||
await writeFile(join(bookDir, "chapters", "0001_开局.md"), "第一章正文", "utf-8");
|
||||
await writeFile(join(bookDir, "chapters", "0002_升级.md"), "第二章正文", "utf-8");
|
||||
await writeFile(join(bookDir, "story", "state", "current_state.json"), JSON.stringify({ facts: ["主角在东城"] }), "utf-8");
|
||||
await writeFile(join(bookDir, "story", "state", "hooks.json"), JSON.stringify({ hooks: [] }), "utf-8");
|
||||
await writeFile(join(bookDir, "story", "author_intent.md"), "# 作者意图\n复仇主线", "utf-8");
|
||||
await writeFile(join(bookDir, "story", "current_focus.md"), "# 当前聚焦\n推进证据链", "utf-8");
|
||||
await writeFile(join(bookDir, "story", "current_state.md"), "# 当前状态\n主角在东城", "utf-8");
|
||||
await writeFile(join(bookDir, "story", "pending_hooks.md"), "| hook_id | 描述 |\n| --- | --- |\n| hook-03 | 遗嘱 |", "utf-8");
|
||||
await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# 故事框架\n都市复仇", "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every canonical file under bookDir (excluding the forecast output
|
||||
* directory) so tests can assert that forecast operations never touch canon.
|
||||
*/
|
||||
export async function snapshotCanonicalFiles(bookDir: string): Promise<ReadonlyMap<string, string>> {
|
||||
const snapshot = new Map<string, string>();
|
||||
await walk(bookDir, bookDir, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function walk(root: string, dir: string, snapshot: Map<string, string>): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
const rel = relative(root, path);
|
||||
if (rel.startsWith(join("story", "runtime", "narrative-forecasts"))) continue;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(root, path, snapshot);
|
||||
} else {
|
||||
snapshot.set(rel, await readFile(path, "utf-8"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function makeForecast(overrides: Partial<NarrativeForecast> = {}): NarrativeForecast {
|
||||
return {
|
||||
version: 1,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BaseAgent } from "../agents/base.js";
|
||||
import type { LLMMessage } from "../llm/provider.js";
|
||||
import {
|
||||
parseForecastModelOutput,
|
||||
type ForecastModelOutput,
|
||||
} from "./schema.js";
|
||||
import {
|
||||
buildForecastRepairPrompt,
|
||||
buildForecastSystemPrompt,
|
||||
buildForecastUserPrompt,
|
||||
type ForecastLanguage,
|
||||
} from "./prompts.js";
|
||||
|
||||
export interface ForecastGenerationInput {
|
||||
readonly contextMarkdown: string;
|
||||
readonly divergence: string;
|
||||
readonly branchCount: number;
|
||||
readonly horizon: number;
|
||||
readonly baseChapter: number;
|
||||
readonly language: ForecastLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-call forecast generator with one validation-driven retry: if the
|
||||
* first response fails JSON/schema/branch-count validation, the error is fed
|
||||
* back to the model once. A second failure surfaces as a hard error — the
|
||||
* runner then writes nothing to disk.
|
||||
*/
|
||||
export class NarrativeForecastAgent extends BaseAgent {
|
||||
get name(): string {
|
||||
return "narrative-forecast";
|
||||
}
|
||||
|
||||
async generateBranches(input: ForecastGenerationInput): Promise<ForecastModelOutput> {
|
||||
const messages: ReadonlyArray<LLMMessage> = [
|
||||
{ role: "system", content: buildForecastSystemPrompt(input.language) },
|
||||
{ role: "user", content: buildForecastUserPrompt(input, input.language) },
|
||||
];
|
||||
const maxTokens = estimateForecastMaxTokens(input.branchCount, input.horizon);
|
||||
|
||||
const first = await this.chat(messages, { temperature: 0.6, maxTokens });
|
||||
let firstError: unknown;
|
||||
try {
|
||||
return validateGeneratedOutput(parseForecastModelOutput(first.content), input.branchCount);
|
||||
} catch (error) {
|
||||
firstError = error;
|
||||
this.log?.warn(`[narrative-forecast] model output invalid, retrying once: ${String(error)}`);
|
||||
}
|
||||
|
||||
const retry = await this.chat([
|
||||
...messages,
|
||||
{ role: "assistant", content: first.content },
|
||||
{ role: "user", content: buildForecastRepairPrompt(String(firstError), input.language) },
|
||||
], { temperature: 0.4, maxTokens });
|
||||
return validateGeneratedOutput(parseForecastModelOutput(retry.content), input.branchCount);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeneratedOutput(output: ForecastModelOutput, expectedBranches: number): ForecastModelOutput {
|
||||
if (output.branches.length !== expectedBranches) {
|
||||
throw new Error(
|
||||
`narrative forecast model returned ${output.branches.length} branches, expected exactly ${expectedBranches}.`,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Planning material is compact; scale headroom with branch count and horizon.
|
||||
// zh chars run ~1.5 tokens each, so this deliberately over-provisions for en.
|
||||
function estimateForecastMaxTokens(branchCount: number, horizon: number): number {
|
||||
return Math.max(8192, branchCount * (horizon * 220 + 1600));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Bilingual prompt builders for the narrative forecast agent, organized the
|
||||
// same way as prompts/short-fiction.ts: each builder switches on language.
|
||||
|
||||
export type ForecastLanguage = "zh" | "en";
|
||||
|
||||
export interface ForecastPromptInput {
|
||||
readonly contextMarkdown: string;
|
||||
readonly divergence: string;
|
||||
readonly branchCount: number;
|
||||
readonly horizon: number;
|
||||
readonly baseChapter: number;
|
||||
}
|
||||
|
||||
export function buildForecastSystemPrompt(language: ForecastLanguage): string {
|
||||
if (language === "en") {
|
||||
return [
|
||||
"You are the narrative forecast assistant for a long-form novel.",
|
||||
"Task: starting from the canonical context and the author's divergence point, project several mutually isolated, non-canonical candidate futures for the author to compare.",
|
||||
"Rules:",
|
||||
"- Branches are mutually exclusive: each assumes a different resolution of the divergence point and must not reference or depend on sibling branches.",
|
||||
"- Branches are planning material, not prose: beats describe what happens, not scene-level detail.",
|
||||
"- Respect canon: every projection must stay consistent with established facts, character locks, and world rules; any necessary conflict must be listed under risks.",
|
||||
"- Output exactly one JSON object. No explanations, no markdown headings, no code fences.",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
"你是长篇小说的叙事推演助手。",
|
||||
"任务:从正史上下文和作者给出的分歧点出发,推演多个相互隔离的非正史候选未来分支,供作者并排比较。",
|
||||
"规则:",
|
||||
"- 分支之间互斥:每个分支对分歧点做出不同走向的假设,不得引用或依赖其他分支。",
|
||||
"- 分支是规划材料,不是正文:节拍只写“发生了什么”,不写场景级细节。",
|
||||
"- 尊重正史:所有推演必须与既有事实、人设锁和世界规则一致;确需冲突时必须写进 risks。",
|
||||
"- 只输出一个 JSON 对象,不要输出解释、markdown 标题或代码围栏。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildForecastUserPrompt(input: ForecastPromptInput, language: ForecastLanguage): string {
|
||||
const firstChapter = input.baseChapter + 1;
|
||||
if (language === "en") {
|
||||
return [
|
||||
input.contextMarkdown,
|
||||
"",
|
||||
"## Divergence point",
|
||||
"",
|
||||
input.divergence,
|
||||
"",
|
||||
"## Output requirements",
|
||||
"",
|
||||
`Produce exactly ${input.branchCount} candidate branches. Each branch covers roughly ${input.horizon} future chapters starting at chapter ${firstChapter}.`,
|
||||
"Return JSON with exactly this shape (field names must match):",
|
||||
forecastJsonShape(firstChapter, "en"),
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
input.contextMarkdown,
|
||||
"",
|
||||
"## 分歧点",
|
||||
"",
|
||||
input.divergence,
|
||||
"",
|
||||
"## 输出要求",
|
||||
"",
|
||||
`生成恰好 ${input.branchCount} 个候选分支。每个分支覆盖从第 ${firstChapter} 章开始、约 ${input.horizon} 章的未来走向。`,
|
||||
"输出 JSON,结构如下(字段名必须完全一致):",
|
||||
forecastJsonShape(firstChapter, "zh"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildForecastRepairPrompt(validationError: string, language: ForecastLanguage): string {
|
||||
if (language === "en") {
|
||||
return [
|
||||
`Your previous output failed validation: ${validationError}`,
|
||||
"Re-output the complete JSON object only, fixing the problem above. No explanations, no code fences.",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`你上一次的输出未通过校验:${validationError}`,
|
||||
"请修正上述问题后重新输出完整 JSON 对象,只输出 JSON,不要解释,不要代码围栏。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function forecastJsonShape(firstChapter: number, language: ForecastLanguage): string {
|
||||
if (language === "en") {
|
||||
return [
|
||||
"{",
|
||||
' "branches": [',
|
||||
" {",
|
||||
' "title": "short branch title",',
|
||||
' "premise": "the assumption this branch makes about the divergence point",',
|
||||
` "beats": [{ "chapter": integer chapter number starting at ${firstChapter}, "summary": "what happens in that chapter" }],`,
|
||||
' "characterDecisions": [{ "character": "name", "decision": "the key decision this character makes" }],',
|
||||
' "projectedChanges": {',
|
||||
' "characters": ["projected character state changes"],',
|
||||
' "relationships": ["projected relationship changes"],',
|
||||
' "world": ["projected world/faction changes"],',
|
||||
' "hooks": ["which hooks advance, fire, or break"]',
|
||||
" },",
|
||||
' "risks": [{ "kind": "continuity|causality|character", "description": "consistency risk" }],',
|
||||
' "uncertainties": ["open uncertainties"],',
|
||||
' "intentAlignment": { "score": integer 0-100, "rationale": "how well this matches the author intent and current focus" }',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
"{",
|
||||
' "branches": [',
|
||||
" {",
|
||||
' "title": "分支短标题",',
|
||||
' "premise": "该分支对分歧点做出的前提与假设",',
|
||||
` "beats": [{ "chapter": 从 ${firstChapter} 开始的整数章号, "summary": "该章发生什么" }],`,
|
||||
' "characterDecisions": [{ "character": "人物名", "decision": "该人物做出的关键决策" }],',
|
||||
' "projectedChanges": {',
|
||||
' "characters": ["人物状态预计变化"],',
|
||||
' "relationships": ["关系预计变化"],',
|
||||
' "world": ["世界/势力预计变化"],',
|
||||
' "hooks": ["哪些伏笔被推进、引爆或破坏"]',
|
||||
" },",
|
||||
' "risks": [{ "kind": "continuity|causality|character", "description": "一致性风险" }],',
|
||||
' "uncertainties": ["不确定因素"],',
|
||||
' "intentAlignment": { "score": 0到100的整数, "rationale": "与作者意图和当前聚焦的匹配说明" }',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ForecastBranch, NarrativeForecast } from "./schema.js";
|
||||
|
||||
// Deterministic markdown renderers for forecast artifacts. Both documents are
|
||||
// derived purely from forecast.json so re-rendering never needs another LLM
|
||||
// call and tests stay clock-free.
|
||||
|
||||
export function renderForecastComparisonMarkdown(forecast: NarrativeForecast): string {
|
||||
const zh = forecast.language === "zh";
|
||||
const header = zh
|
||||
? [
|
||||
`# 叙事推演对比:${forecast.divergence}`,
|
||||
"",
|
||||
`- 推演 ID:${forecast.forecastId}`,
|
||||
`- 书籍:${forecast.bookId}`,
|
||||
`- 基准章节:第 ${forecast.baseChapter} 章`,
|
||||
`- 推演跨度:约 ${forecast.horizon} 章`,
|
||||
`- 生成时间:${forecast.createdAt}`,
|
||||
"",
|
||||
"> 本文件是非正史规划材料,不会改动正文或权威状态。",
|
||||
]
|
||||
: [
|
||||
`# Narrative forecast comparison: ${forecast.divergence}`,
|
||||
"",
|
||||
`- Forecast id: ${forecast.forecastId}`,
|
||||
`- Book: ${forecast.bookId}`,
|
||||
`- Base chapter: ${forecast.baseChapter}`,
|
||||
`- Horizon: ~${forecast.horizon} chapters`,
|
||||
`- Created at: ${forecast.createdAt}`,
|
||||
"",
|
||||
"> Non-canonical planning material. Nothing here modifies prose or authoritative state.",
|
||||
];
|
||||
|
||||
const tableHeader = zh
|
||||
? ["| 分支 | 标题 | 意图匹配 | 风险数 | 前提 |", "| --- | --- | --- | --- | --- |"]
|
||||
: ["| Branch | Title | Intent fit | Risks | Premise |", "| --- | --- | --- | --- | --- |"];
|
||||
const tableRows = forecast.branches.map((branch) =>
|
||||
`| ${branch.branchId} | ${escapeCell(branch.title)} | ${branch.intentAlignment.score} | ${branch.risks.length} | ${escapeCell(branch.premise)} |`);
|
||||
|
||||
const sections = forecast.branches.map((branch) => renderBranchSection(branch, zh));
|
||||
|
||||
return [...header, "", ...tableHeader, ...tableRows, "", sections.join("\n\n")].join("\n");
|
||||
}
|
||||
|
||||
export function renderSelectedBranchPlanMarkdown(input: {
|
||||
readonly forecast: NarrativeForecast;
|
||||
readonly branch: ForecastBranch;
|
||||
readonly selectedAt: string;
|
||||
readonly stale: boolean;
|
||||
}): string {
|
||||
const { forecast, branch } = input;
|
||||
const zh = forecast.language === "zh";
|
||||
|
||||
const staleWarning = input.stale
|
||||
? (zh
|
||||
? "> ⚠️ 该推演已过期:正史章节或状态在推演生成后发生了变化。以下计划基于旧上下文,采用前请重新核对,必要时重新生成推演。"
|
||||
: "> ⚠️ This forecast is stale: canonical chapters or state changed after it was generated. The plan below is based on outdated context — re-check before applying, and regenerate if needed.")
|
||||
: "";
|
||||
|
||||
const header = zh
|
||||
? [
|
||||
`# 已选分支计划:${branch.title}`,
|
||||
"",
|
||||
`- 推演 ID:${forecast.forecastId}`,
|
||||
`- 分支:${branch.branchId}`,
|
||||
`- 分歧点:${forecast.divergence}`,
|
||||
`- 基准章节:第 ${forecast.baseChapter} 章`,
|
||||
`- 选择时间:${input.selectedAt}`,
|
||||
]
|
||||
: [
|
||||
`# Selected branch plan: ${branch.title}`,
|
||||
"",
|
||||
`- Forecast id: ${forecast.forecastId}`,
|
||||
`- Branch: ${branch.branchId}`,
|
||||
`- Divergence: ${forecast.divergence}`,
|
||||
`- Base chapter: ${forecast.baseChapter}`,
|
||||
`- Selected at: ${input.selectedAt}`,
|
||||
];
|
||||
|
||||
const footer = zh
|
||||
? "> 本计划不修改正史。要把它应用到大纲、章节意图或权威状态,需要另行确认的操作(v1 不自动执行)。"
|
||||
: "> This plan does not modify canon. Applying it to the outline, chapter intents, or authoritative state is a separate, explicitly confirmed operation (not automated in v1).";
|
||||
|
||||
return [
|
||||
...header,
|
||||
...(staleWarning ? ["", staleWarning] : []),
|
||||
"",
|
||||
renderBranchSection(branch, zh, { headingLevel: 2, includeBranchId: false }),
|
||||
"",
|
||||
footer,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderBranchSection(
|
||||
branch: ForecastBranch,
|
||||
zh: boolean,
|
||||
options: { readonly headingLevel?: number; readonly includeBranchId?: boolean } = {},
|
||||
): string {
|
||||
const level = "#".repeat(options.headingLevel ?? 2);
|
||||
const sub = `${level}#`;
|
||||
const heading = options.includeBranchId === false
|
||||
? `${level} ${branch.title}`
|
||||
: `${level} ${branch.branchId}:${branch.title}`;
|
||||
|
||||
const labels = zh
|
||||
? {
|
||||
premise: "前提与假设",
|
||||
beats: "未来章节节拍",
|
||||
decisions: "人物决策",
|
||||
changes: "预计变化",
|
||||
characters: "人物",
|
||||
relationships: "关系",
|
||||
world: "世界",
|
||||
hooks: "伏笔",
|
||||
risks: "一致性风险",
|
||||
uncertainties: "不确定性",
|
||||
alignment: "作者意图匹配度",
|
||||
chapterPrefix: (n: number) => `第 ${n} 章`,
|
||||
none: "(无)",
|
||||
}
|
||||
: {
|
||||
premise: "Premise and assumptions",
|
||||
beats: "Future chapter beats",
|
||||
decisions: "Character decisions",
|
||||
changes: "Projected changes",
|
||||
characters: "Characters",
|
||||
relationships: "Relationships",
|
||||
world: "World",
|
||||
hooks: "Hooks",
|
||||
risks: "Consistency risks",
|
||||
uncertainties: "Uncertainties",
|
||||
alignment: "Author intent alignment",
|
||||
chapterPrefix: (n: number) => `Chapter ${n}`,
|
||||
none: "(none)",
|
||||
};
|
||||
|
||||
const list = (items: ReadonlyArray<string>): string =>
|
||||
items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : labels.none;
|
||||
|
||||
return [
|
||||
heading,
|
||||
"",
|
||||
`${sub} ${labels.premise}`,
|
||||
"",
|
||||
branch.premise,
|
||||
"",
|
||||
`${sub} ${labels.beats}`,
|
||||
"",
|
||||
list(branch.beats.map((beat) => `${labels.chapterPrefix(beat.chapter)}:${beat.summary}`)),
|
||||
"",
|
||||
`${sub} ${labels.decisions}`,
|
||||
"",
|
||||
list(branch.characterDecisions.map((decision) => `${decision.character}:${decision.decision}`)),
|
||||
"",
|
||||
`${sub} ${labels.changes}`,
|
||||
"",
|
||||
`- ${labels.characters}:${joinOrNone(branch.projectedChanges.characters, labels.none)}`,
|
||||
`- ${labels.relationships}:${joinOrNone(branch.projectedChanges.relationships, labels.none)}`,
|
||||
`- ${labels.world}:${joinOrNone(branch.projectedChanges.world, labels.none)}`,
|
||||
`- ${labels.hooks}:${joinOrNone(branch.projectedChanges.hooks, labels.none)}`,
|
||||
"",
|
||||
`${sub} ${labels.risks}`,
|
||||
"",
|
||||
list(branch.risks.map((risk) => `[${risk.kind}] ${risk.description}`)),
|
||||
"",
|
||||
`${sub} ${labels.uncertainties}`,
|
||||
"",
|
||||
list([...branch.uncertainties]),
|
||||
"",
|
||||
`${sub} ${labels.alignment}`,
|
||||
"",
|
||||
`${branch.intentAlignment.score}/100 — ${branch.intentAlignment.rationale}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function joinOrNone(items: ReadonlyArray<string>, none: string): string {
|
||||
return items.length > 0 ? items.join(";") : none;
|
||||
}
|
||||
|
||||
function escapeCell(value: string): string {
|
||||
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { AgentContext } from "../agents/base.js";
|
||||
import { assertSafeBookId } from "../utils/book-id.js";
|
||||
import { NarrativeForecastAgent } from "./agent.js";
|
||||
import { buildForecastContext, renderForecastContextMarkdown } from "./context-builder.js";
|
||||
import { renderForecastComparisonMarkdown, renderSelectedBranchPlanMarkdown } from "./render.js";
|
||||
import {
|
||||
FORECAST_DEFAULT_BRANCHES,
|
||||
FORECAST_DEFAULT_HORIZON,
|
||||
FORECAST_MAX_BRANCHES,
|
||||
FORECAST_MAX_HORIZON,
|
||||
FORECAST_MIN_BRANCHES,
|
||||
FORECAST_MIN_HORIZON,
|
||||
type ForecastBranch,
|
||||
type NarrativeForecast,
|
||||
} from "./schema.js";
|
||||
import { ForecastStore, type ForecastStoreOptions } from "./store.js";
|
||||
|
||||
// The three v1 operations from RFC #342: create / get / select. All artifacts
|
||||
// stay under story/runtime/narrative-forecasts/<forecastId>/ — no operation
|
||||
// here may write story/state/*.json, story/*.md control docs, or chapters/.
|
||||
|
||||
export interface CreateNarrativeForecastOptions {
|
||||
readonly projectRoot: string;
|
||||
readonly bookId: string;
|
||||
readonly divergence: string;
|
||||
readonly branchCount?: number;
|
||||
readonly horizon?: number;
|
||||
readonly runtime: AgentContext;
|
||||
readonly determinism?: ForecastStoreOptions;
|
||||
readonly onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface NarrativeForecastCreateResult {
|
||||
readonly forecast: NarrativeForecast;
|
||||
readonly forecastJsonPath: string;
|
||||
readonly comparisonPath: string;
|
||||
}
|
||||
|
||||
export async function createNarrativeForecast(
|
||||
options: CreateNarrativeForecastOptions,
|
||||
): Promise<NarrativeForecastCreateResult> {
|
||||
const bookId = assertSafeBookId(options.bookId, "forecast.bookId");
|
||||
const divergence = options.divergence.trim();
|
||||
if (!divergence) {
|
||||
throw new Error("divergence is required: describe the decision point the forecast should branch on.");
|
||||
}
|
||||
const branchCount = boundedInteger(
|
||||
options.branchCount, FORECAST_DEFAULT_BRANCHES, "branchCount", FORECAST_MIN_BRANCHES, FORECAST_MAX_BRANCHES,
|
||||
);
|
||||
const horizon = boundedInteger(
|
||||
options.horizon, FORECAST_DEFAULT_HORIZON, "horizon", FORECAST_MIN_HORIZON, FORECAST_MAX_HORIZON,
|
||||
);
|
||||
const bookDir = await resolveBookDir(options.projectRoot, bookId);
|
||||
|
||||
options.onProgress?.("Reading canonical context...");
|
||||
const context = await buildForecastContext({ bookDir, bookId });
|
||||
|
||||
options.onProgress?.(`Projecting ${branchCount} candidate branches...`);
|
||||
const agent = new NarrativeForecastAgent(options.runtime);
|
||||
const modelOutput = await agent.generateBranches({
|
||||
contextMarkdown: renderForecastContextMarkdown(context),
|
||||
divergence,
|
||||
branchCount,
|
||||
horizon,
|
||||
baseChapter: context.baseChapter,
|
||||
language: context.language,
|
||||
});
|
||||
|
||||
const store = new ForecastStore(bookDir, options.determinism);
|
||||
const forecast: NarrativeForecast = {
|
||||
version: 1,
|
||||
forecastId: await store.allocateForecastId(),
|
||||
bookId,
|
||||
createdAt: store.now().toISOString(),
|
||||
language: context.language,
|
||||
divergence,
|
||||
horizon,
|
||||
baseChapter: context.baseChapter,
|
||||
contextFingerprint: context.contextFingerprint,
|
||||
status: "active",
|
||||
branches: modelOutput.branches.map((branch, index) => ({
|
||||
branchId: `branch-${index + 1}`,
|
||||
...branch,
|
||||
})),
|
||||
};
|
||||
|
||||
options.onProgress?.("Writing forecast artifacts...");
|
||||
const paths = await store.save(forecast, renderForecastComparisonMarkdown(forecast));
|
||||
return { forecast, ...paths };
|
||||
}
|
||||
|
||||
export interface GetNarrativeForecastOptions {
|
||||
readonly projectRoot: string;
|
||||
readonly bookId: string;
|
||||
readonly forecastId: string;
|
||||
}
|
||||
|
||||
export interface NarrativeForecastGetResult {
|
||||
readonly forecast: NarrativeForecast;
|
||||
readonly stale: boolean;
|
||||
readonly forecastJsonPath: string;
|
||||
readonly comparisonPath: string;
|
||||
}
|
||||
|
||||
export async function getNarrativeForecast(
|
||||
options: GetNarrativeForecastOptions,
|
||||
): Promise<NarrativeForecastGetResult> {
|
||||
const bookId = assertSafeBookId(options.bookId, "forecast.bookId");
|
||||
const bookDir = await resolveBookDir(options.projectRoot, bookId);
|
||||
const store = new ForecastStore(bookDir);
|
||||
|
||||
let forecast = await store.load(options.forecastId);
|
||||
const stale = await isForecastStale(bookDir, bookId, forecast);
|
||||
if (stale && forecast.status === "active") {
|
||||
// Persist the stale marker so later readers see it without recomputing.
|
||||
forecast = await store.markStale(forecast);
|
||||
}
|
||||
|
||||
return {
|
||||
forecast,
|
||||
stale,
|
||||
forecastJsonPath: store.forecastJsonPath(forecast.forecastId),
|
||||
comparisonPath: store.comparisonPath(forecast.forecastId),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelectNarrativeBranchOptions {
|
||||
readonly projectRoot: string;
|
||||
readonly bookId: string;
|
||||
readonly forecastId: string;
|
||||
readonly branchId: string;
|
||||
readonly determinism?: ForecastStoreOptions;
|
||||
}
|
||||
|
||||
export interface NarrativeForecastSelectResult {
|
||||
readonly forecast: NarrativeForecast;
|
||||
readonly branch: ForecastBranch;
|
||||
readonly stale: boolean;
|
||||
readonly planPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one branch: writes ONLY selected-branch-plan.md. Applying the plan
|
||||
* to the outline / chapter intents / canonical state is a separate,
|
||||
* user-confirmed operation outside v1.
|
||||
*/
|
||||
export async function selectNarrativeBranch(
|
||||
options: SelectNarrativeBranchOptions,
|
||||
): Promise<NarrativeForecastSelectResult> {
|
||||
const bookId = assertSafeBookId(options.bookId, "forecast.bookId");
|
||||
const bookDir = await resolveBookDir(options.projectRoot, bookId);
|
||||
const store = new ForecastStore(bookDir, options.determinism);
|
||||
|
||||
const forecast = await store.load(options.forecastId);
|
||||
const branch = forecast.branches.find((candidate) => candidate.branchId === options.branchId);
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
`Branch "${options.branchId}" not found in forecast "${forecast.forecastId}". `
|
||||
+ `Available branches: ${forecast.branches.map((candidate) => candidate.branchId).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stale = await isForecastStale(bookDir, bookId, forecast);
|
||||
const planPath = await store.writeSelectedPlan(
|
||||
forecast.forecastId,
|
||||
renderSelectedBranchPlanMarkdown({
|
||||
forecast,
|
||||
branch,
|
||||
selectedAt: store.now().toISOString(),
|
||||
stale,
|
||||
}),
|
||||
);
|
||||
|
||||
return { forecast, branch, stale, planPath };
|
||||
}
|
||||
|
||||
async function isForecastStale(
|
||||
bookDir: string,
|
||||
bookId: string,
|
||||
forecast: NarrativeForecast,
|
||||
): Promise<boolean> {
|
||||
if (forecast.status === "stale") return true;
|
||||
const context = await buildForecastContext({ bookDir, bookId });
|
||||
return context.contextFingerprint !== forecast.contextFingerprint;
|
||||
}
|
||||
|
||||
async function resolveBookDir(projectRoot: string, bookId: string): Promise<string> {
|
||||
const bookDir = join(projectRoot, "books", bookId);
|
||||
try {
|
||||
await access(join(bookDir, "book.json"));
|
||||
} catch {
|
||||
throw new Error(`Book "${bookId}" not found under ${join(projectRoot, "books")}.`);
|
||||
}
|
||||
return bookDir;
|
||||
}
|
||||
|
||||
function boundedInteger(value: number | undefined, fallback: number, name: string, min: number, max: number): number {
|
||||
const parsed = value ?? fallback;
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new Error(`${name} must be an integer between ${min} and ${max}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -397,6 +397,46 @@ export {
|
||||
type ShortFictionRunRuntimes,
|
||||
} from "./pipeline/short-fiction-runner.js";
|
||||
|
||||
// Narrative forecast (issue #342): non-canonical multi-branch story projection
|
||||
export {
|
||||
FORECAST_MIN_BRANCHES,
|
||||
FORECAST_MAX_BRANCHES,
|
||||
FORECAST_DEFAULT_BRANCHES,
|
||||
FORECAST_MIN_HORIZON,
|
||||
FORECAST_MAX_HORIZON,
|
||||
FORECAST_DEFAULT_HORIZON,
|
||||
NarrativeForecastSchema,
|
||||
ForecastBranchSchema,
|
||||
parseForecastModelOutput,
|
||||
type NarrativeForecast,
|
||||
type ForecastBranch,
|
||||
type ForecastBeat,
|
||||
type ForecastRisk,
|
||||
type ForecastStatus,
|
||||
type ForecastModelOutput,
|
||||
} from "./forecast/schema.js";
|
||||
export { ForecastStore, assertSafeForecastId, type ForecastStoreOptions } from "./forecast/store.js";
|
||||
export {
|
||||
buildForecastContext,
|
||||
computeContextFingerprint,
|
||||
renderForecastContextMarkdown,
|
||||
type ForecastContext,
|
||||
type ForecastContextSections,
|
||||
} from "./forecast/context-builder.js";
|
||||
export { NarrativeForecastAgent, type ForecastGenerationInput } from "./forecast/agent.js";
|
||||
export { renderForecastComparisonMarkdown, renderSelectedBranchPlanMarkdown } from "./forecast/render.js";
|
||||
export {
|
||||
createNarrativeForecast,
|
||||
getNarrativeForecast,
|
||||
selectNarrativeBranch,
|
||||
type CreateNarrativeForecastOptions,
|
||||
type GetNarrativeForecastOptions,
|
||||
type SelectNarrativeBranchOptions,
|
||||
type NarrativeForecastCreateResult,
|
||||
type NarrativeForecastGetResult,
|
||||
type NarrativeForecastSelectResult,
|
||||
} from "./forecast/runner.js";
|
||||
|
||||
// Agent (pi-agent integration)
|
||||
export * from "./agent/index.js";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user