@fanchao8609/agent_brain_sync 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/src/store.js ADDED
@@ -0,0 +1,499 @@
1
+ // src/store.js — CLI 命令实现:图谱读写层。
2
+ // 命令: init / board / status / load / task / query / lint
3
+ import { promises as fs } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { requireBrain, findBrainRoot, brainPath } from './index.js';
6
+ import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, moveBlocked, insertDoneGrouped } from './todo.js';
7
+ import { editFile, SKIP } from './lock.js';
8
+ import { appendWrapup, strandedFor, wrapupLogPath } from './wrapup.js';
9
+
10
+ // ---------- init: 建 .brain/ 骨架 ----------
11
+ const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
12
+ const BRAIN_FILES = ['index.md', 'log.md', 'todo.md'];
13
+
14
+ export async function cmdInit({ dir }) {
15
+ const root = resolveProjectDir(dir);
16
+ const brain = join(root, '.brain');
17
+ try {
18
+ await fs.access(brain);
19
+ } catch (e) {
20
+ if (e && e.code !== 'ENOENT') throw e;
21
+ return createSkeleton(root, brain);
22
+ }
23
+ // 已存在 → 校验结构完整性;损坏则明确报错并指引 repair(不静默半初始化)
24
+ const report = await reportBrain({ dir: root });
25
+ if (report.ok) throw new Error(`已存在: ${brain} (abort)`);
26
+ throw new Error(
27
+ `已存在但结构不完整:\n ${report.problems.join('\n ')}\n` +
28
+ ` → 轻则补齐: abs init --repair 重则重建: 删掉 ${brain} 后重跑 abs init`
29
+ );
30
+ }
31
+
32
+ async function createSkeleton(root, brain) {
33
+ for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
34
+ await fs.writeFile(join(brain, 'index.md'), indexTemplate(), 'utf8');
35
+ await fs.writeFile(join(brain, 'log.md'), logTemplate(), 'utf8');
36
+ await fs.writeFile(join(brain, 'todo.md'), todoTemplate(), 'utf8');
37
+ return `✓ 已建图谱 ${brain}\n (模板见 SKILL.md「文件标准」)`;
38
+ }
39
+
40
+ /** 结构体检:6 目录 + 3 文件逐一核对。ok=false 时 problems 列出缺失项。 */
41
+ export async function reportBrain({ dir }) {
42
+ const root = resolveProjectDir(dir);
43
+ const brain = join(root, '.brain');
44
+ const problems = [];
45
+ for (const d of BRAIN_DIRS) {
46
+ try {
47
+ const st = await fs.stat(join(brain, d));
48
+ if (!st.isDirectory()) problems.push(`缺目录 ${d}/ (被文件占位)`);
49
+ } catch {
50
+ problems.push(`缺目录 ${d}/`);
51
+ }
52
+ }
53
+ for (const f of BRAIN_FILES) {
54
+ try {
55
+ await fs.access(join(brain, f));
56
+ } catch {
57
+ problems.push(`缺文件 ${f}`);
58
+ }
59
+ }
60
+ return { ok: problems.length === 0, problems, brain };
61
+ }
62
+
63
+ /** init --repair: 只补缺失骨架,绝不覆盖已有文件。 */
64
+ export async function cmdRepair({ dir }) {
65
+ const root = resolveProjectDir(dir);
66
+ const brain = join(root, '.brain');
67
+ try {
68
+ await fs.access(brain);
69
+ } catch {
70
+ return createSkeleton(root, brain); // 整个图谱都没有 = 全新建
71
+ }
72
+ const fixed = [];
73
+ for (const d of BRAIN_DIRS) {
74
+ try {
75
+ const st = await fs.stat(join(brain, d));
76
+ if (!st.isDirectory()) {
77
+ await fs.rm(join(brain, d), { force: true });
78
+ await fs.mkdir(join(brain, d), { recursive: true });
79
+ fixed.push(`${d}/`);
80
+ }
81
+ } catch {
82
+ await fs.mkdir(join(brain, d), { recursive: true });
83
+ fixed.push(`${d}/`);
84
+ }
85
+ }
86
+ for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
87
+ try {
88
+ await fs.access(join(brain, f));
89
+ } catch {
90
+ await fs.writeFile(join(brain, f), tpl(), 'utf8');
91
+ fixed.push(f);
92
+ }
93
+ }
94
+ if (!fixed.length) return `✓ 结构完整,无需修复: ${brain}`;
95
+ return `✓ 已补齐 ${fixed.join(', ')} → ${brain}\n (已有文件一律不覆盖)`;
96
+ }
97
+
98
+ function resolveProjectDir(dir) {
99
+ if (!dir) return process.cwd();
100
+ return resolve(dir);
101
+ }
102
+
103
+ export function indexTemplate() {
104
+ return [
105
+ '# 🗂 图谱索引',
106
+ '',
107
+ '本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。',
108
+ '',
109
+ '## 当前路线 (Roadmap)',
110
+ '## Concepts',
111
+ '## Entities',
112
+ '## Sources',
113
+ '## Syntheses',
114
+ '## Sessions',
115
+ '',
116
+ ].join('\n');
117
+ }
118
+
119
+ export function logTemplate() {
120
+ return ['# 🗒 操作日志', '', '## [YYYY-MM-DD] ingest | 沉淀 <slug>', ''].join('\n');
121
+ }
122
+
123
+ // ---------- board: 看板 ----------
124
+ export async function cmdBoard({ dir }) {
125
+ const root = await requireBrain(dir || process.cwd());
126
+ return boardText(root);
127
+ }
128
+
129
+ // ---------- status: 定位报告 + 图谱概要 ----------
130
+ export async function cmdStatus({ dir }) {
131
+ const root = await requireBrain(dir || process.cwd());
132
+ const entries = await listBrainFiles(root);
133
+ return [
134
+ `📂 abs → 项目: ${root}`,
135
+ `图谱: ${brainPath(root)}`,
136
+ '',
137
+ entries.length ? entries.join('\n') : '(图谱为空)',
138
+ ].join('\n');
139
+ }
140
+
141
+ async function listBrainFiles(root) {
142
+ const out = [];
143
+ const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
144
+ for (const d of dirs) {
145
+ const p = brainPath(root, d);
146
+ try {
147
+ const files = (await fs.readdir(p)).filter((f) => f.endsWith('.md'));
148
+ out.push(`${d}/: ${files.length} 页`);
149
+ } catch { out.push(`${d}/: 0 页`); }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // ---------- load: 开机读状态 ----------
155
+ export async function cmdLoad({ dir }) {
156
+ const root = await requireBrain(dir || process.cwd());
157
+ const todo = await readTodo(root);
158
+ const index = await readIfExists(brainPath(root, 'index.md'));
159
+ const log = await readIfExists(brainPath(root, 'log.md'));
160
+ const stranded = await strandedFor(root);
161
+ const sections = [
162
+ `📂 abs → 项目: ${root}`,
163
+ '--- 当前路线 (index.md) ---',
164
+ index || '(index.md 为空)',
165
+ '',
166
+ '--- Todo 看板 (todo.md) ---',
167
+ todo.trim() || '(todo.md 为空)',
168
+ '',
169
+ '--- 最近动作 (log.md, 末尾 5 条) ---',
170
+ tailLines(log, 5) || '(log.md 为空)',
171
+ ];
172
+ if (stranded.length) {
173
+ const rows = stranded.map((t) => {
174
+ const bp = t.bp.length ? `\n ${t.bp.map((b) => `↳ 断点: ${b}`).join('\n ')}` : '';
175
+ return ` - ${t.body}${bp}`;
176
+ });
177
+ sections.splice(
178
+ 0, 1,
179
+ `📂 abs → 项目: ${root}`,
180
+ '⏳ 上会话滞留(未 done,先对账)',
181
+ rows.join('\n'),
182
+ '→ 完成: abs task done <id>;未完: abs task note <id> --note 断点',
183
+ ''
184
+ );
185
+ }
186
+ return sections.join('\n');
187
+ }
188
+
189
+ // ---------- wrapup: 滞留快照(B)/ load 内展示由 cmdLoad 完成(A) ----------
190
+ export async function cmdWrapup({ dir }) {
191
+ const root = await requireBrain(dir || process.cwd());
192
+ return appendWrapup(root);
193
+ }
194
+
195
+ async function readIfExists(p) {
196
+ try { return (await fs.readFile(p, 'utf8')).trim(); } catch { return ''; }
197
+ }
198
+
199
+ function tailLines(text, n) {
200
+ const lines = text.split('\n').filter((l) => l.trim());
201
+ return lines.slice(-n).join('\n');
202
+ }
203
+
204
+ // ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
205
+ export async function cmdLog({ dir, title, kind = 'dev' }) {
206
+ const root = await requireBrain(dir || process.cwd());
207
+ const p = brainPath(root, 'log.md');
208
+ const stamp = localStamp();
209
+ const clean = String(title || '').replace(/\n/g, ' ').slice(0, 100); // 整行含前缀 ≤120
210
+ const line = `## [${stamp}] ${kind} | ${clean}`;
211
+ await editFile(p, (cur) => {
212
+ const text = cur ?? '# 🗒 操作日志\n';
213
+ // 倒序:新行插在标题后(若已是模板占位行则替换它)
214
+ const lines = text.split('\n');
215
+ const headerIdx = lines.findIndex((l) => l.startsWith('#'));
216
+ lines.splice(headerIdx + 1, 0, line);
217
+ return { text: lines.join('\n') };
218
+ });
219
+ return `✓ log → ${p}\n ${line}`;
220
+ }
221
+
222
+ // ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
223
+ // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
224
+ // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
225
+ export async function cmdTask({ dir, action, id, section, note }) {
226
+ const root = await requireBrain(dir || process.cwd());
227
+ if (action === 'start') {
228
+ const text = `${id}${note ? ' — ' + note : ''}`;
229
+ const r = await upsertTask(root, {
230
+ section: section || 'Today / In Progress',
231
+ text: `${text} (认领 ${today()})`,
232
+ });
233
+ return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n ${id}${note ? ' — ' + note : ''}`;
234
+ }
235
+ if (action === 'blocked') {
236
+ const r = await moveBlocked(root, { id, reason: note });
237
+ return r.msg;
238
+ }
239
+ if (action === 'note') {
240
+ if (!note) return '用法: abs task note <id> --note "断点/进度"(实时落 ↳ 断点 行)';
241
+ const r = await setBreakpoint(root, { id, text: note });
242
+ return r.msg;
243
+ }
244
+ if (action === 'done') {
245
+ // 找到匹配 id 的行,勾选并归位 Done(简化:若行在某 section 则标记完成)
246
+ const res = await markDone(brainPath(root, 'todo.md'), id);
247
+ return res;
248
+ }
249
+ throw new Error(`unknown task action: ${action}`);
250
+ }
251
+
252
+ async function markDone(file, id) {
253
+ const res = await editFile(file, (text) => {
254
+ const lines = text.split('\n');
255
+ let changed = false;
256
+ let moved = null;
257
+ const kept = [];
258
+ for (let i = 0; i < lines.length; i++) {
259
+ const l = lines[i];
260
+ if (!moved && l.startsWith('- [ ]') && l.includes(id)) {
261
+ changed = true;
262
+ const head = l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`;
263
+ const bp = [];
264
+ while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
265
+ moved = [head, ...bp];
266
+ continue;
267
+ }
268
+ kept.push(l);
269
+ }
270
+ if (!changed || !moved) return SKIP;
271
+ // 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
272
+ return { text: insertDoneGrouped(kept.join('\n'), moved) };
273
+ });
274
+ return res === SKIP
275
+ ? `(未找到含 "${id}" 的未完成任务行)`
276
+ : `✓ 已完成并归位 Done: ${id}`;
277
+ }
278
+
279
+ // ---------- show: 查看 index/todo/log(只读面) ----------
280
+ export async function cmdShow({ dir, view }) {
281
+ const v = String(view || '').toLowerCase();
282
+ if (!['todo', 'index', 'log'].includes(v)) {
283
+ return '用法: abs <todo|index|log> — todo=看板(原 board), index=图谱索引, log=操作流水';
284
+ }
285
+ const root = await requireBrain(dir || process.cwd());
286
+ const p = brainPath(root, `${v === 'todo' ? 'todo' : v}.md`);
287
+ // todo 走 readTodo(含老格式惰性迁移写回);index/log 直接读
288
+ let text;
289
+ try {
290
+ text = v === 'todo'
291
+ ? await readTodo(root) // 迁移老格式 In Progress/Todo → B4
292
+ : (await fs.readFile(p, 'utf8')).trim();
293
+ } catch {
294
+ return `${v}.md 不存在于 ${brainPath(root)}。初始化/补齐: abs init --repair`;
295
+ }
296
+ if (!text) return `(${v}.md 为空)`;
297
+ return v === 'todo' ? boardText(root, text) : text;
298
+ }
299
+ // ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
300
+ const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
301
+
302
+ export async function cmdQuery({ dir, terms }) {
303
+ const words = (terms || []).map((w) => String(w).trim()).filter(Boolean);
304
+ if (!words.length) {
305
+ return '用法: abs query <词1> [词2 …] — 多词 OR 检索 .brain/ 全部知识页';
306
+ }
307
+ let root;
308
+ try {
309
+ root = await requireBrain(dir || process.cwd());
310
+ } catch {
311
+ return `未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init`;
312
+ }
313
+ const hits = [];
314
+ const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
315
+ for (const d of dirs) {
316
+ const p = brainPath(root, d);
317
+ let files;
318
+ try {
319
+ files = await fs.readdir(p);
320
+ } catch {
321
+ continue;
322
+ }
323
+ for (const f of files) {
324
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
325
+ const full = join(p, f);
326
+ const body = await fs.readFile(full, 'utf8').catch(() => '');
327
+ const matched = words.filter((w) => body.toLowerCase().includes(w.toLowerCase()));
328
+ if (matched.length) {
329
+ hits.push({ full, slug: f.replace(/\.md$/, ''), matched, snippet: firstHitLine(body, words) });
330
+ }
331
+ }
332
+ }
333
+ if (!hits.length) return `query [${words.join(', ')}]: 无命中。用 abs lint 看图谱健康;首次使用先 abs init。`;
334
+ const lines = hits.map((h) => `📄 ${h.slug} (命中: ${h.matched.join(', ')})\n ${h.snippet}`);
335
+ return [`query [${words.join(', ')}] → ${hits.length} 页:`, '', ...lines].join('\n');
336
+ }
337
+
338
+ function firstHitLine(body, words) {
339
+ const lower = body.toLowerCase();
340
+ for (const line of body.split('\n')) {
341
+ const l = line.toLowerCase();
342
+ if (words.some((w) => l.includes(w.toLowerCase())) && line.trim() && !KNOWN_SLUG_HINT.test(line)) {
343
+ return line.trim().slice(0, 100);
344
+ }
345
+ }
346
+ return '';
347
+ }
348
+
349
+ // ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
350
+ const NOTE_DEDUP_MS = 60 * 1000;
351
+
352
+ export async function cmdNote({ dir, text, tags }) {
353
+ const clean = String(text || '').trim();
354
+ if (!clean) return '用法: abs note "经验/坑/技巧一句话"(落 sources/ 暂存页,实时不流失)';
355
+ let root;
356
+ try {
357
+ root = await requireBrain(dir || process.cwd());
358
+ } catch {
359
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
360
+ }
361
+ const srcDir = brainPath(root, 'sources');
362
+ await fs.mkdir(srcDir, { recursive: true });
363
+ // 幂等: 同文本 60s 内只落一份
364
+ const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
365
+ for (const f of existing) {
366
+ const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
367
+ if (body.includes(clean)) {
368
+ return `• 60s 内已落同文本 → ${f} (跳过重复)`;
369
+ }
370
+ }
371
+ const tagList = String(tags || '').split(',').map((t) => t.trim()).filter(Boolean);
372
+ const fmTags = ['source', ...tagList].join(', ');
373
+ const slugSrc = clean.slice(0, 24).replace(/[^\w一-鿿]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
374
+ const file = `${today()}-${slugSrc || 'note'}.md`;
375
+ const body = [
376
+ '---',
377
+ `tags: [${fmTags}]`,
378
+ `updated: ${today()}`,
379
+ 'status: draft',
380
+ '---',
381
+ '',
382
+ `# 来源:${clean.slice(0, 40)}`,
383
+ '',
384
+ `## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
385
+ `- ${clean}`,
386
+ '',
387
+ '## 关联连接',
388
+ '(提炼成 concepts 规律页后,在此挂双链到该页)',
389
+ '',
390
+ ].join('\n');
391
+ // 源文件是新写唯一文件:tmp+rename 原子落盘(避免并发读读到半写文件)
392
+ const srcFile = join(srcDir, file);
393
+ const tmp = join(srcDir, `.${file}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
394
+ await fs.writeFile(tmp, body, 'utf8');
395
+ await fs.rename(tmp, srcFile);
396
+ // index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
397
+ const iP = brainPath(root, 'index.md');
398
+ const slug = file.replace(/\.md$/, '');
399
+ const line = `- [[${slug}]] — ${clean.slice(0, 40)}`;
400
+ await editFile(iP, (index) => {
401
+ if (!index || index.includes(`[[${slug}]]`)) return SKIP;
402
+ const sIdx = index.indexOf('## Sources');
403
+ if (sIdx === -1) return SKIP;
404
+ const after = index.indexOf('\n## ', sIdx + 1);
405
+ const next = after === -1
406
+ ? `${index.replace(/\s*$/, '')}\n${line}\n`
407
+ : index.slice(0, after) + `\n${line}` + index.slice(after);
408
+ return { text: next };
409
+ });
410
+ await cmdLog({ dir: root, title: clean.slice(0, 60), kind: 'note' });
411
+ return `✓ 经验暂存 → sources/${file}\n ${clean.slice(0, 60)}`;
412
+ }
413
+ // ---------- lint: 体检(与 scripts/lint.sh 同规则的 Node 版,供 CLI/MCP 直调) ----------
414
+ export async function cmdLint({ dir }) {
415
+ let root;
416
+ try {
417
+ root = await requireBrain(dir || process.cwd());
418
+ } catch {
419
+ return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
420
+ }
421
+ const vault = brainPath(root);
422
+ const pages = await listPages(vault);
423
+ const names = new Set(pages.map((p) => p.slug));
424
+ const linkedNames = new Set(pages.flatMap((p) => p.links));
425
+ const issues = [];
426
+
427
+ for (const pg of pages) {
428
+ if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
429
+ for (const ln of pg.links) {
430
+ if (/slug|Name|name|Date|页面名$/.test(ln)) issues.push(`TEMPLATE-LINK: ${pg.rel} -> [[${ln}]]`);
431
+ if (!names.has(ln)) issues.push(`DEAD-LINK: ${pg.rel} -> [[${ln}]]`);
432
+ }
433
+ // ORPHAN: sources/ 暂存页豁免(暂存线索天然孤立,提炼成 concept 前不强制挂链)
434
+ if (pg.dir !== 'sources' && !pg.links.length && !linkedNames.has(pg.slug)) {
435
+ issues.push(`ORPHAN-PAGE: ${pg.rel} (no links out, no links in)`);
436
+ }
437
+ if (/知识冲突/.test(pg.body) && /status: draft/.test(pg.frontmatter)) {
438
+ issues.push(`UNRESOLVED-CONFLICT: ${pg.rel}`);
439
+ }
440
+ if (['concepts', 'entities', 'syntheses'].includes(pg.dir)) {
441
+ if (pg.lines > 150 || pg.bytes > 5 * 1024) {
442
+ issues.push(`OVER-SIZE: ${pg.rel} (${pg.lines}L/${pg.bytes}B > 150L/5120B; 拆或外链)`);
443
+ }
444
+ }
445
+ if (pg.dir !== 'sources' && !pg.indexed) {
446
+ issues.push(`INDEX-MISSING: ${pg.rel} not listed as [[${pg.slug}]] in index.md`);
447
+ }
448
+ }
449
+
450
+ const nsrc = pages.filter((p) => p.dir === 'sources').length;
451
+ if (nsrc > 10) issues.push(`SOURCES-PILED-UP: sources/ has ${nsrc} files > 10; 提炼归档旧 source`);
452
+
453
+ const n = issues.length;
454
+ return [
455
+ ...(issues.length ? issues : []),
456
+ '',
457
+ `lint: ${n} problem(s).`,
458
+ n === 0 ? '✓ 图谱健康' : '',
459
+ ].filter(Boolean).join('\n');
460
+ }
461
+
462
+ const PAGE_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
463
+
464
+ async function listPages(vault) {
465
+ let indexText = '';
466
+ try {
467
+ indexText = await fs.readFile(join(vault, 'index.md'), 'utf8');
468
+ } catch { /* no index yet */ }
469
+ const pages = [];
470
+ for (const d of PAGE_DIRS) {
471
+ const dp = join(vault, d);
472
+ let files;
473
+ try {
474
+ files = await fs.readdir(dp);
475
+ } catch {
476
+ continue;
477
+ }
478
+ for (const f of files) {
479
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
480
+ const full = join(dp, f);
481
+ const body = await fs.readFile(full, 'utf8').catch(() => '');
482
+ const fm = body.match(/^---\n([\s\S]*?)\n---/);
483
+ const links = [...new Set([...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((m) => m[1].split('|')[0]))];
484
+ pages.push({
485
+ dir: d,
486
+ rel: `${d}/${f}`,
487
+ slug: f.replace(/\.md$/, ''),
488
+ body,
489
+ frontmatter: fm ? fm[1] : '',
490
+ hasFrontmatter: body.startsWith('---\n'),
491
+ links,
492
+ lines: body.split('\n').length,
493
+ bytes: Buffer.byteLength(body, 'utf8'),
494
+ indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
495
+ });
496
+ }
497
+ }
498
+ return pages;
499
+ }