novelforge-agent 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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/dist/src/cli/index.js +125 -0
  4. package/dist/src/core/contextBuilder.js +128 -0
  5. package/dist/src/core/fileNames.js +41 -0
  6. package/dist/src/core/index.js +9 -0
  7. package/dist/src/core/projectDiscovery.js +141 -0
  8. package/dist/src/core/projectStore.js +85 -0
  9. package/dist/src/core/prompts/en-US.js +363 -0
  10. package/dist/src/core/prompts/types.js +1 -0
  11. package/dist/src/core/prompts/zh-CN.js +362 -0
  12. package/dist/src/core/prompts.js +15 -0
  13. package/dist/src/core/retrieval/chunker.js +77 -0
  14. package/dist/src/core/retrieval/index.js +125 -0
  15. package/dist/src/core/retrieval/tokenizer.js +43 -0
  16. package/dist/src/core/retrieval/types.js +1 -0
  17. package/dist/src/core/schemas.js +91 -0
  18. package/dist/src/core/steps/architecture.js +16 -0
  19. package/dist/src/core/steps/chapter.js +16 -0
  20. package/dist/src/core/steps/chapterReview.js +16 -0
  21. package/dist/src/core/steps/chapterRevision.js +20 -0
  22. package/dist/src/core/steps/continuityReview.js +13 -0
  23. package/dist/src/core/steps/crossChapterReview.js +15 -0
  24. package/dist/src/core/steps/index.js +20 -0
  25. package/dist/src/core/steps/memoryCard.js +22 -0
  26. package/dist/src/core/steps/novelMetadata.js +12 -0
  27. package/dist/src/core/steps/storyBible.js +13 -0
  28. package/dist/src/core/steps/types.js +7 -0
  29. package/dist/src/core/types.js +1 -0
  30. package/dist/src/core/workflow.js +186 -0
  31. package/dist/src/mcp/server.js +13 -0
  32. package/dist/src/mcp/tools.js +126 -0
  33. package/package.json +61 -0
  34. package/src/cli/index.ts +147 -0
  35. package/src/core/contextBuilder.ts +131 -0
  36. package/src/core/fileNames.ts +48 -0
  37. package/src/core/index.ts +9 -0
  38. package/src/core/projectDiscovery.ts +174 -0
  39. package/src/core/projectStore.ts +111 -0
  40. package/src/core/prompts/en-US.ts +376 -0
  41. package/src/core/prompts/types.ts +28 -0
  42. package/src/core/prompts/zh-CN.ts +375 -0
  43. package/src/core/prompts.ts +27 -0
  44. package/src/core/retrieval/chunker.ts +80 -0
  45. package/src/core/retrieval/index.ts +136 -0
  46. package/src/core/retrieval/tokenizer.ts +44 -0
  47. package/src/core/retrieval/types.ts +24 -0
  48. package/src/core/schemas.ts +101 -0
  49. package/src/core/steps/architecture.ts +17 -0
  50. package/src/core/steps/chapter.ts +17 -0
  51. package/src/core/steps/chapterReview.ts +17 -0
  52. package/src/core/steps/chapterRevision.ts +21 -0
  53. package/src/core/steps/continuityReview.ts +14 -0
  54. package/src/core/steps/crossChapterReview.ts +16 -0
  55. package/src/core/steps/index.ts +25 -0
  56. package/src/core/steps/memoryCard.ts +23 -0
  57. package/src/core/steps/novelMetadata.ts +13 -0
  58. package/src/core/steps/storyBible.ts +14 -0
  59. package/src/core/steps/types.ts +21 -0
  60. package/src/core/types.ts +115 -0
  61. package/src/core/workflow.ts +250 -0
  62. package/src/mcp/server.ts +15 -0
  63. package/src/mcp/tools.ts +227 -0
@@ -0,0 +1,375 @@
1
+ import { BuiltPrompt, PromptBuildInput, PromptPack } from './types.js';
2
+
3
+ function strictJsonOutputRules(): string {
4
+ return [
5
+ 'JSON 输出规则:',
6
+ '- 只输出合法 JSON,不要输出 Markdown、代码块、解释或前后缀文本。',
7
+ '- 字符串必须使用双引号。',
8
+ '- 不要使用 undefined、NaN、Infinity、注释或尾随逗号。',
9
+ '- 数组字段必须输出真实数组,不要输出字符串化数组。',
10
+ ].join('\n');
11
+ }
12
+
13
+ function buildMetadataPrompt(input: PromptBuildInput): BuiltPrompt {
14
+ return {
15
+ purpose: 'novel_metadata',
16
+ expectedFormat: 'JSON matching NovelMetadataSchema',
17
+ prompt: `你是一名长篇网络小说总策划。请根据用户提示生成新小说的基础信息。
18
+
19
+ ## 用户提示词
20
+ ${input.state.initialPrompt}
21
+
22
+ ## 输出要求
23
+ 请只输出合法 JSON,格式如下:
24
+ {
25
+ "title": "小说名称",
26
+ "genre": "题材",
27
+ "premise": "故事前提,80-200字",
28
+ "language": "zh-CN",
29
+ "style": "文风说明",
30
+ "coreCast": [
31
+ {
32
+ "name": "角色姓名",
33
+ "role": "角色定位",
34
+ "description": "角色描述"
35
+ }
36
+ ]
37
+ }
38
+
39
+ 要求:
40
+ - title、genre、premise、language、style 必须是非空字符串。
41
+ - coreCast 至少包含 1 个核心人物。
42
+ - premise 要能支撑长篇连载,不要只写一句设定。
43
+ ${strictJsonOutputRules()}`,
44
+ };
45
+ }
46
+
47
+ function buildStoryBiblePrompt(input: PromptBuildInput): BuiltPrompt {
48
+ return {
49
+ purpose: 'story_bible',
50
+ expectedFormat: 'Markdown',
51
+ prompt: `你是一名故事圣经编辑。请为这部长篇小说生成可长期复用的 Markdown 故事圣经。
52
+
53
+ ## 用户提示词
54
+ ${input.state.initialPrompt}
55
+
56
+ ${input.context ? `## 已有上下文\n${input.context}\n` : ''}## 输出结构
57
+ 请用 Markdown 输出,至少包含:
58
+
59
+ ## 核心人物
60
+ - 主要人物的目标、弱点、关系、长期变化方向。
61
+
62
+ ## 人物关系
63
+ - 核心关系、冲突关系、隐藏关系和后续可推进点。
64
+
65
+ ## 世界规则
66
+ - 题材相关的硬规则、限制、代价、社会结构或势力格局。
67
+
68
+ ## 主线与支线
69
+ - 主线目标。
70
+ - 至少 3 条长期伏笔或支线。
71
+
72
+ ## 风格约束
73
+ - 叙事语气、节奏、禁忌写法、人物对白边界。
74
+
75
+ 要求:
76
+ - 内容要能被后续章节生成反复引用。
77
+ - 不要写成章节正文。
78
+ - 不要输出 JSON。`,
79
+ };
80
+ }
81
+
82
+ function buildArchitecturePrompt(input: PromptBuildInput): BuiltPrompt {
83
+ return {
84
+ purpose: 'architecture',
85
+ expectedFormat: 'JSON matching ArchitecturePayloadSchema',
86
+ prompt: `你是一名长篇小说总架构师。请生成全本、卷、章三级架构。
87
+
88
+ ## 用户提示词
89
+ ${input.state.initialPrompt}
90
+
91
+ ## 目标
92
+ - 本次至少生成 ${input.state.targetChapters} 个章架构。
93
+ - 全本架构负责长期主线和结局方向。
94
+ - 卷架构负责阶段冲突、高潮和卷尾钩子。
95
+ - 章架构必须只覆盖本章应发生的内容,不要提前泄露后续具体事件。
96
+
97
+ ${input.context ? `## 已有上下文\n${input.context}\n` : ''}## 输出要求
98
+ 请只输出合法 JSON,格式如下:
99
+ {
100
+ "full": "完整全书主线、阶段推进、核心冲突、主题和结局方向",
101
+ "volumes": [
102
+ {
103
+ "id": "v1",
104
+ "title": "卷标题",
105
+ "summary": "本卷目标、冲突、高潮和卷尾钩子",
106
+ "order": 1
107
+ }
108
+ ],
109
+ "chapters": [
110
+ {
111
+ "chapterNumber": 1,
112
+ "title": "章标题",
113
+ "volumeId": "v1",
114
+ "summary": "本章剧情摘要",
115
+ "requiredBeats": ["必须完成的情节点1", "必须完成的情节点2"]
116
+ }
117
+ ]
118
+ }
119
+
120
+ 要求:
121
+ - chapters.length 必须大于等于 ${input.state.targetChapters}。
122
+ - chapterNumber 从 1 开始连续递增。
123
+ - volumeId 必须引用 volumes 中存在的 id。
124
+ - requiredBeats 至少 1 条,且必须具体可执行。
125
+ ${strictJsonOutputRules()}`,
126
+ };
127
+ }
128
+
129
+ function buildChapterPrompt(input: PromptBuildInput): BuiltPrompt {
130
+ return {
131
+ purpose: 'chapter',
132
+ expectedFormat: 'Markdown',
133
+ prompt: `你是一位擅长创作长篇网络小说的职业作者。请直接完成第 ${input.state.currentChapter} 章正文。
134
+
135
+ ## 执行优先级
136
+ 1. 先严格遵守“本章架构、用户补充要求、故事圣经硬约束、上一章承接”。
137
+ 2. 再参考“历史相关记忆、历史原文证据”保证一致性。
138
+ 3. 最后才参考“全本/本卷远场规划”,且不得提前写出尚未发生的情节。
139
+
140
+ ## 风格与字数
141
+ - 文风必须与本书题材、世界观、人物身份、情感基调一致。
142
+ - 语言要自然、稳定、可读,优先服务叙事推进、人物塑造和情绪积累。
143
+ - 对话必须符合人物身份、关系和处境;重要情绪尽量通过动作、神态、节奏、潜台词体现。
144
+ - 场景描写要有必要的感官细节与氛围支撑,但篇幅服务剧情,不要空转。
145
+ - 冲突、转折、悬念和章末钩子要清晰,保证阅读推进感。
146
+
147
+ ## 执行规则
148
+ - 只写本章架构明确覆盖的内容,不得提前写后续章节具体事件或人物揭示。
149
+ - 不得新增本章架构未授权的主要人物;功能性角色只能轻描淡写。
150
+ - 所有人物称谓、物品、场景、能力、时间线必须与既有设定一致。
151
+ - 如果上一章结尾仍在动作、对话或同一场景中,本章开头必须连续衔接。
152
+ - 禁止无代价越级碾压、强行降智配角、突兀机械反转、硬灌设定、空洞抒情。
153
+ - 禁止总结腔、条目腔、说教腔,不要输出解释性前言。
154
+
155
+ ${input.context ? `## 生成上下文\n${input.context}\n` : ''}## 输出要求
156
+ - 输出 Markdown。
157
+ - 第一行使用本章标题作为 H1,例如:# 章标题
158
+ - H1 后直接进入正文。`,
159
+ };
160
+ }
161
+
162
+ function buildMemoryPrompt(input: PromptBuildInput): BuiltPrompt {
163
+ return {
164
+ purpose: 'memory_card',
165
+ expectedFormat: 'JSON matching MemoryCardSchema',
166
+ prompt: `你是一名长篇小说连续性编辑。请从第 ${input.state.currentChapter} 章正文中提取记忆卡。
167
+
168
+ ${input.context ? `## 当前章上下文\n${input.context}\n` : ''}## 输出要求
169
+ 请只输出合法 JSON,格式如下:
170
+ {
171
+ "summary": "本章摘要",
172
+ "keyEvents": ["关键事件1"],
173
+ "entities": [
174
+ {
175
+ "name": "人物/地点/物品/组织名称",
176
+ "type": "person | location | item | faction | concept",
177
+ "state": "本章结束时的状态"
178
+ }
179
+ ],
180
+ "facts": [
181
+ {
182
+ "subject": "主体",
183
+ "predicate": "关系或动作",
184
+ "object": "客体"
185
+ }
186
+ ],
187
+ "stateChanges": [
188
+ {
189
+ "entity": "实体",
190
+ "before": "变化前",
191
+ "after": "变化后"
192
+ }
193
+ ],
194
+ "openThreads": ["尚未解决的伏笔、承诺、危险或疑问"]
195
+ }
196
+
197
+ 要求:
198
+ - 只记录已经在本章发生或被确认的信息。
199
+ - 不要推测后续剧情。
200
+ - facts 和 stateChanges 要具体,便于后续章节引用。
201
+ ${strictJsonOutputRules()}`,
202
+ };
203
+ }
204
+
205
+ function buildContinuityReviewPrompt(input: PromptBuildInput): BuiltPrompt {
206
+ const end = Math.max(input.state.targetChapters, input.state.currentChapter - 1);
207
+ return {
208
+ purpose: 'continuity_review',
209
+ expectedFormat: 'JSON matching ContinuityReviewSchema',
210
+ prompt: `你是一名长篇小说连续性审稿人。请审阅第 1-${end} 章的连续性问题。
211
+
212
+ ${input.context ? `## 审阅上下文\n${input.context}\n` : ''}## 审阅重点
213
+ - 人物状态、位置、伤势、关系是否前后矛盾。
214
+ - 物品归属、能力限制、世界规则是否被破坏。
215
+ - 伏笔是否被误解、遗漏或提前揭示。
216
+ - 章节架构要求是否被正文违反。
217
+
218
+ ## 输出要求
219
+ 请只输出合法 JSON,格式如下:
220
+ {
221
+ "range": {
222
+ "start": 1,
223
+ "end": ${end}
224
+ },
225
+ "status": "clean",
226
+ "issues": [
227
+ {
228
+ "severity": "low | medium | high",
229
+ "description": "问题描述",
230
+ "evidence": "来自上下文的证据",
231
+ "suggestion": "修复建议"
232
+ }
233
+ ]
234
+ }
235
+
236
+ 要求:
237
+ - 没有问题时 status 使用 "clean",issues 输出空数组。
238
+ - 有问题时 status 使用 "issues_found"。
239
+ - evidence 必须具体,不能只写“疑似不一致”。
240
+ ${strictJsonOutputRules()}`,
241
+ };
242
+ }
243
+
244
+ function buildChapterReviewPrompt(input: PromptBuildInput): BuiltPrompt {
245
+ const chapter = input.state.pendingAction?.chapterNumber ?? input.state.currentChapter;
246
+ return {
247
+ purpose: 'chapter_review',
248
+ expectedFormat: 'JSON matching ChapterReviewSchema',
249
+ prompt: `你是一名严格的章节审稿编辑。请审阅指定章节是否存在内部问题以及与既有设定的冲突。
250
+
251
+ ${input.context ? `## 审阅上下文\n${input.context}\n` : ''}## 审阅重点
252
+ - 人物声音、动机、状态是否符合故事圣经与历史记忆。
253
+ - 世界规则、物品归属、能力边界是否被破坏。
254
+ - 时间线、地点、与上一章结尾的衔接是否一致。
255
+ - 是否完成本章架构 requiredBeats。
256
+ - 文风:节奏、硬灌设定、突兀反转、空洞抒情、条目腔。
257
+
258
+ ## 输出要求
259
+ 请只输出合法 JSON,格式如下:
260
+ {
261
+ "chapterNumber": ${chapter},
262
+ "status": "clean",
263
+ "issues": [
264
+ {
265
+ "severity": "low | medium | high",
266
+ "category": "character | world | timeline | item | knowledge | pacing | style | architecture",
267
+ "description": "具体问题",
268
+ "evidence": "引用或转述能证明问题的具体段落",
269
+ "suggestion": "具体修复建议"
270
+ }
271
+ ]
272
+ }
273
+
274
+ 要求:
275
+ - 没有问题时 status 为 "clean",issues 输出空数组。
276
+ - 有问题时 status 为 "issues_found"。
277
+ - evidence 必须具体,不能写"疑似"、"可能"。
278
+ ${strictJsonOutputRules()}`,
279
+ };
280
+ }
281
+
282
+ function buildChapterRevisionPrompt(input: PromptBuildInput): BuiltPrompt {
283
+ const chapter = input.state.pendingAction?.chapterNumber ?? input.state.currentChapter;
284
+ return {
285
+ purpose: 'chapter_revision',
286
+ expectedFormat: 'Markdown',
287
+ prompt: `你是这本长篇小说第 ${chapter} 章的修订作者。请根据审稿反馈,产出修订后的完整章节正文。
288
+
289
+ ## 优先级
290
+ 1. 必须修复反馈中的每一条问题,不可遗漏。
291
+ 2. 不要破坏已经能用的部分:结构、语气、人物声音、有效对白。
292
+ 3. 保持与故事圣经、上一章承接、已有记忆的连续性。
293
+
294
+ ## 风格规则
295
+ - 保持原章节的标题与 Markdown 结构。
296
+ - 不要输出条目化总结、变更日志、"我修改了什么"之类的解释文字。
297
+ - 不得新增本章架构未授权的主要人物或主线伏笔。
298
+
299
+ ${input.context ? `## 修订上下文\n${input.context}\n` : ''}## 输出要求
300
+ - 仅输出 Markdown。
301
+ - 第一行使用本章标题作为 H1:\`# 章标题\`。
302
+ - H1 后直接输出修订后的完整正文,不要输出 diff 标记。`,
303
+ };
304
+ }
305
+
306
+ function buildCrossChapterReviewPrompt(input: PromptBuildInput): BuiltPrompt {
307
+ const range = input.state.pendingAction?.range ?? { start: 1, end: input.state.currentChapter - 1 };
308
+ return {
309
+ purpose: 'cross_chapter_review',
310
+ expectedFormat: 'JSON matching CrossChapterReviewSchema',
311
+ prompt: `你是资深连续性编辑。请同时审阅第 ${range.start}-${range.end} 章,找出单章审阅无法发现的跨章节冲突。
312
+
313
+ ${input.context ? `## 审阅上下文\n${input.context}\n` : ''}## 审阅重点
314
+ - 人物状态在多章之间漂移(例如伤势忽然消失)。
315
+ - 不同章节确认的事实互相冲突。
316
+ - 被悄悄遗忘或丢弃的伏笔。
317
+ - 后续章节破坏前面建立的世界规则。
318
+ - 只能跨章看到的节奏问题。
319
+
320
+ ## 输出要求
321
+ 请只输出合法 JSON,格式如下:
322
+ {
323
+ "range": { "start": ${range.start}, "end": ${range.end} },
324
+ "status": "clean",
325
+ "issues": [
326
+ {
327
+ "severity": "low | medium | high",
328
+ "chapters": [${range.start}, ${range.end}],
329
+ "description": "具体问题",
330
+ "evidence": "按章节引用冲突段落或记忆条目",
331
+ "suggestion": "具体修复建议"
332
+ }
333
+ ]
334
+ }
335
+
336
+ 要求:
337
+ - chapters 必须列出问题涉及的所有章节。
338
+ - evidence 必须引用具体章节内容或记忆,不能模糊。
339
+ ${strictJsonOutputRules()}`,
340
+ };
341
+ }
342
+
343
+ function buildPromptForStep(input: PromptBuildInput): BuiltPrompt {
344
+ switch (input.state.currentStep) {
345
+ case 'novel_metadata':
346
+ return buildMetadataPrompt(input);
347
+ case 'story_bible':
348
+ return buildStoryBiblePrompt(input);
349
+ case 'architecture':
350
+ return buildArchitecturePrompt(input);
351
+ case 'chapter':
352
+ return buildChapterPrompt(input);
353
+ case 'memory_card':
354
+ return buildMemoryPrompt(input);
355
+ case 'continuity_review':
356
+ return buildContinuityReviewPrompt(input);
357
+ case 'chapter_review':
358
+ return buildChapterReviewPrompt(input);
359
+ case 'chapter_revision':
360
+ return buildChapterRevisionPrompt(input);
361
+ case 'cross_chapter_review':
362
+ return buildCrossChapterReviewPrompt(input);
363
+ case 'complete':
364
+ return {
365
+ purpose: 'continuity_review',
366
+ expectedFormat: 'No output required',
367
+ prompt: 'The workflow is complete.',
368
+ };
369
+ }
370
+ }
371
+
372
+ export const zhCNPromptPack: PromptPack = {
373
+ buildPromptForStep,
374
+ strictJsonOutputRules,
375
+ };
@@ -0,0 +1,27 @@
1
+ import { enUSPromptPack } from './prompts/en-US.js';
2
+ import { zhCNPromptPack } from './prompts/zh-CN.js';
3
+ import { BuiltPrompt, PromptBuildInput, PromptPack } from './prompts/types.js';
4
+
5
+ export type {
6
+ BuiltPrompt,
7
+ PromptBuildInput,
8
+ PromptPack,
9
+ PromptPurpose,
10
+ } from './prompts/types.js';
11
+
12
+ const promptPacks: Record<PromptBuildInput['state']['language'], PromptPack> = {
13
+ 'zh-CN': zhCNPromptPack,
14
+ 'en-US': enUSPromptPack,
15
+ };
16
+
17
+ function getPromptPack(language: PromptBuildInput['state']['language']): PromptPack {
18
+ return promptPacks[language] || zhCNPromptPack;
19
+ }
20
+
21
+ export function strictJsonOutputRules(language: PromptBuildInput['state']['language'] = 'zh-CN'): string {
22
+ return getPromptPack(language).strictJsonOutputRules();
23
+ }
24
+
25
+ export function buildPromptForStep(input: PromptBuildInput): BuiltPrompt {
26
+ return getPromptPack(input.state.language).buildPromptForStep(input);
27
+ }
@@ -0,0 +1,80 @@
1
+ import { MemoryCard } from '../types.js';
2
+ import { RetrievalDoc } from './types.js';
3
+
4
+ const PARAGRAPH_MIN_CHARS = 40;
5
+ const PARAGRAPH_MAX_CHARS = 600;
6
+
7
+ function splitMarkdownParagraphs(markdown: string): string[] {
8
+ const stripped = markdown.replace(/^#[^\n]*\n?/, ''); // drop leading H1 title
9
+ const raw = stripped.split(/\n\s*\n+/);
10
+ const merged: string[] = [];
11
+ for (const part of raw) {
12
+ const trimmed = part.trim();
13
+ if (!trimmed) continue;
14
+ if (trimmed.length < PARAGRAPH_MIN_CHARS && merged.length) {
15
+ merged[merged.length - 1] = `${merged[merged.length - 1]}\n${trimmed}`;
16
+ } else {
17
+ merged.push(trimmed);
18
+ }
19
+ }
20
+ // Cap super-long paragraphs into halves so a single 3000-char block does not dominate.
21
+ const capped: string[] = [];
22
+ for (const para of merged) {
23
+ if (para.length <= PARAGRAPH_MAX_CHARS) {
24
+ capped.push(para);
25
+ continue;
26
+ }
27
+ for (let i = 0; i < para.length; i += PARAGRAPH_MAX_CHARS) {
28
+ capped.push(para.slice(i, i + PARAGRAPH_MAX_CHARS));
29
+ }
30
+ }
31
+ return capped;
32
+ }
33
+
34
+ export function chunkChapter(chapterNumber: number, markdown: string): RetrievalDoc[] {
35
+ const paragraphs = splitMarkdownParagraphs(markdown);
36
+ return paragraphs.map((text, index) => ({
37
+ id: `chapter:${chapterNumber}:p:${index}`,
38
+ type: 'chapter',
39
+ chapterNumber,
40
+ text,
41
+ }));
42
+ }
43
+
44
+ export function chunkStoryBible(markdown: string): RetrievalDoc[] {
45
+ const sections = markdown.split(/^##\s+/m);
46
+ const docs: RetrievalDoc[] = [];
47
+ sections.forEach((section, index) => {
48
+ const trimmed = section.trim();
49
+ if (!trimmed) return;
50
+ const newlineIdx = trimmed.indexOf('\n');
51
+ const heading = newlineIdx > -1 ? trimmed.slice(0, newlineIdx).trim() : `section-${index}`;
52
+ const body = newlineIdx > -1 ? trimmed.slice(newlineIdx + 1).trim() : '';
53
+ if (!body) return;
54
+ docs.push({
55
+ id: `bible:${index}:${heading}`,
56
+ type: 'bible',
57
+ section: heading,
58
+ text: `${heading}\n${body}`,
59
+ });
60
+ });
61
+ return docs;
62
+ }
63
+
64
+ export function chunkMemoryCard(chapterNumber: number, card: MemoryCard): RetrievalDoc[] {
65
+ const lines = [
66
+ `chapter ${chapterNumber} summary`,
67
+ card.summary,
68
+ ...card.keyEvents.map((event) => `event: ${event}`),
69
+ ...card.facts.map((f) => `fact: ${f.subject} ${f.predicate} ${f.object}`),
70
+ ...card.stateChanges.map((s) => `state: ${s.entity} ${s.before} -> ${s.after}`),
71
+ ...card.entities.map((e) => `entity ${e.type}: ${e.name} - ${e.state}`),
72
+ ...card.openThreads.map((thread) => `open: ${thread}`),
73
+ ];
74
+ return [{
75
+ id: `memory:${chapterNumber}`,
76
+ type: 'memory',
77
+ chapterNumber,
78
+ text: lines.join('\n'),
79
+ }];
80
+ }
@@ -0,0 +1,136 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import MiniSearch from 'minisearch';
4
+ import { MemoryCard } from '../types.js';
5
+ import { tokenize } from './tokenizer.js';
6
+ import { chunkChapter, chunkMemoryCard, chunkStoryBible } from './chunker.js';
7
+ import { RetrievalDoc, RetrievalHit, RetrieveOptions } from './types.js';
8
+
9
+ export type { RetrievalDoc, RetrievalDocType, RetrievalHit, RetrieveOptions } from './types.js';
10
+
11
+ const INDEX_PATH = '.index/lexical.json';
12
+ const MANIFEST_PATH = '.index/manifest.json';
13
+
14
+ const MINISEARCH_OPTIONS = {
15
+ fields: ['text'],
16
+ storeFields: ['type', 'chapterNumber', 'section', 'text'],
17
+ tokenize: (text: string) => tokenize(text),
18
+ processTerm: (term: string) => term,
19
+ searchOptions: {
20
+ tokenize: (text: string) => tokenize(text),
21
+ processTerm: (term: string) => term,
22
+ prefix: false,
23
+ fuzzy: false,
24
+ combineWith: 'OR' as const,
25
+ },
26
+ };
27
+
28
+ interface IndexBundle {
29
+ index: MiniSearch<RetrievalDoc>;
30
+ ids: Set<string>;
31
+ }
32
+
33
+ async function loadBundle(projectPath: string): Promise<IndexBundle> {
34
+ let index: MiniSearch<RetrievalDoc>;
35
+ try {
36
+ const raw = await readFile(join(projectPath, INDEX_PATH), 'utf8');
37
+ index = MiniSearch.loadJSON<RetrievalDoc>(raw, MINISEARCH_OPTIONS);
38
+ } catch {
39
+ index = new MiniSearch<RetrievalDoc>(MINISEARCH_OPTIONS);
40
+ }
41
+ let ids = new Set<string>();
42
+ try {
43
+ const raw = await readFile(join(projectPath, MANIFEST_PATH), 'utf8');
44
+ const parsed = JSON.parse(raw) as { ids?: string[] };
45
+ if (Array.isArray(parsed.ids)) ids = new Set(parsed.ids);
46
+ } catch {
47
+ // manifest missing; bundle starts empty
48
+ }
49
+ return { index, ids };
50
+ }
51
+
52
+ async function persistBundle(projectPath: string, bundle: IndexBundle): Promise<void> {
53
+ const indexFull = join(projectPath, INDEX_PATH);
54
+ const manifestFull = join(projectPath, MANIFEST_PATH);
55
+ await mkdir(dirname(indexFull), { recursive: true });
56
+ await writeFile(indexFull, JSON.stringify(bundle.index), 'utf8');
57
+ await writeFile(manifestFull, JSON.stringify({ ids: Array.from(bundle.ids).sort() }), 'utf8');
58
+ }
59
+
60
+ async function upsert(projectPath: string, removePredicate: (id: string) => boolean, docs: RetrievalDoc[]): Promise<void> {
61
+ const bundle = await loadBundle(projectPath);
62
+ const toRemove: string[] = [];
63
+ for (const id of bundle.ids) {
64
+ if (removePredicate(id)) toRemove.push(id);
65
+ }
66
+ for (const id of toRemove) {
67
+ try {
68
+ bundle.index.discard(id);
69
+ } catch {
70
+ // already absent
71
+ }
72
+ bundle.ids.delete(id);
73
+ }
74
+ if (toRemove.length) {
75
+ await bundle.index.vacuum();
76
+ }
77
+ for (const doc of docs) {
78
+ bundle.index.add(doc);
79
+ bundle.ids.add(doc.id);
80
+ }
81
+ await persistBundle(projectPath, bundle);
82
+ }
83
+
84
+ export async function indexChapter(projectPath: string, chapterNumber: number, markdown: string): Promise<void> {
85
+ const prefix = `chapter:${chapterNumber}:`;
86
+ await upsert(projectPath, (id) => id.startsWith(prefix), chunkChapter(chapterNumber, markdown));
87
+ }
88
+
89
+ export async function indexStoryBible(projectPath: string, markdown: string): Promise<void> {
90
+ await upsert(projectPath, (id) => id.startsWith('bible:'), chunkStoryBible(markdown));
91
+ }
92
+
93
+ export async function indexMemoryCard(projectPath: string, chapterNumber: number, card: MemoryCard): Promise<void> {
94
+ const id = `memory:${chapterNumber}`;
95
+ await upsert(projectPath, (existing) => existing === id, chunkMemoryCard(chapterNumber, card));
96
+ }
97
+
98
+ export async function retrieve(projectPath: string, query: string, options: RetrieveOptions = {}): Promise<RetrievalHit[]> {
99
+ if (!query.trim()) return [];
100
+ const bundle = await loadBundle(projectPath);
101
+ const topK = options.topK ?? 6;
102
+ const raw = bundle.index.search(query, {
103
+ filter: (result: Record<string, unknown>) => {
104
+ const type = result.type as string | undefined;
105
+ const chapterNumber = result.chapterNumber as number | undefined;
106
+ if (options.types && !options.types.includes(type as RetrievalHit['type'])) return false;
107
+ if (options.chapterRange && typeof chapterNumber === 'number') {
108
+ const { start, end } = options.chapterRange;
109
+ if (chapterNumber < start || chapterNumber > end) return false;
110
+ }
111
+ return true;
112
+ },
113
+ });
114
+ const hits: RetrievalHit[] = raw.slice(0, topK).map((r: Record<string, unknown>) => ({
115
+ id: r.id as string,
116
+ type: r.type as RetrievalHit['type'],
117
+ chapterNumber: r.chapterNumber as number | undefined,
118
+ section: r.section as string | undefined,
119
+ text: r.text as string,
120
+ score: r.score as number,
121
+ }));
122
+ return hits;
123
+ }
124
+
125
+ export function formatHits(hits: RetrievalHit[]): string {
126
+ if (!hits.length) return '';
127
+ const lines: string[] = [];
128
+ for (const hit of hits) {
129
+ const tag =
130
+ hit.type === 'chapter' ? `Chapter ${hit.chapterNumber}` :
131
+ hit.type === 'memory' ? `Chapter ${hit.chapterNumber} Memory` :
132
+ `Bible: ${hit.section}`;
133
+ lines.push(`### ${tag} (score ${hit.score.toFixed(2)})\n${hit.text}`);
134
+ }
135
+ return lines.join('\n\n');
136
+ }
@@ -0,0 +1,44 @@
1
+ const CJK_RANGE = /[㐀-鿿]/;
2
+ const ALNUM_RANGE = /[a-z0-9]/;
3
+
4
+ export function isCjk(char: string): boolean {
5
+ return CJK_RANGE.test(char);
6
+ }
7
+
8
+ // Pragmatic tokenizer for mixed Chinese + Latin text without jieba:
9
+ // - Latin / digit runs are lowercased and emitted whole.
10
+ // - CJK characters are emitted as both unigrams and overlapping bigrams.
11
+ // "陈青云走" -> ["陈", "陈青", "青", "青云", "云", "云走", "走"]
12
+ // - Everything else acts as a separator.
13
+ //
14
+ // Unigrams cover names and recall; bigrams give phrase locality so a search for
15
+ // "陈青云" prefers chapters that actually contain that phrase.
16
+ export function tokenize(text: string): string[] {
17
+ const tokens: string[] = [];
18
+ const lowered = text.toLowerCase();
19
+ let alnumBuf = '';
20
+ const flushAlnum = () => {
21
+ if (alnumBuf) {
22
+ tokens.push(alnumBuf);
23
+ alnumBuf = '';
24
+ }
25
+ };
26
+
27
+ for (let i = 0; i < lowered.length; i += 1) {
28
+ const c = lowered[i];
29
+ if (isCjk(c)) {
30
+ flushAlnum();
31
+ tokens.push(c);
32
+ const next = lowered[i + 1];
33
+ if (next && isCjk(next)) {
34
+ tokens.push(c + next);
35
+ }
36
+ } else if (ALNUM_RANGE.test(c)) {
37
+ alnumBuf += c;
38
+ } else {
39
+ flushAlnum();
40
+ }
41
+ }
42
+ flushAlnum();
43
+ return tokens;
44
+ }
@@ -0,0 +1,24 @@
1
+ export type RetrievalDocType = 'chapter' | 'bible' | 'memory';
2
+
3
+ export interface RetrievalDoc {
4
+ id: string;
5
+ type: RetrievalDocType;
6
+ chapterNumber?: number;
7
+ section?: string;
8
+ text: string;
9
+ }
10
+
11
+ export interface RetrievalHit {
12
+ id: string;
13
+ type: RetrievalDocType;
14
+ chapterNumber?: number;
15
+ section?: string;
16
+ text: string;
17
+ score: number;
18
+ }
19
+
20
+ export interface RetrieveOptions {
21
+ topK?: number;
22
+ types?: RetrievalDocType[];
23
+ chapterRange?: { start: number; end: number };
24
+ }