memory-catalog-checks.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. /** Initial catalog enters via the actual pre-step waterfall and normal user/message events. */
  6. export async function checkMemoryCatalog({ host, ctx, workspace, book, service, execute, check, report, withTurn }) {
  7. const { LlmAdapter, createUserMessage } = await import(host === undefined
  8. ? '@deepseek-ai/dsh-llm' : pathToFileURL(host.entry('@deepseek-ai/dsh-llm')).href)
  9. class CatalogAdapter extends LlmAdapter {
  10. async *stream() {
  11. yield { type: 'block-start', index: 0, blockType: 'text' }
  12. yield { type: 'text-delta', index: 0, text: '已读取上下文。' }
  13. yield { type: 'block-end', index: 0, block: { type: 'text', text: '已读取上下文。' } }
  14. yield { type: 'finish', reason: { kind: 'stop' } }
  15. }
  16. }
  17. const releaseAdapter = service('llm').registerAdapter(['catalog-fixture'], new CatalogAdapter())
  18. const agentOptions = { provider: 'catalog-fixture', model: 'fixture' }
  19. const authorDir = path.join(workspace, '书房', '作者记忆')
  20. const entry = (dir, name, fields, body) => {
  21. fs.mkdirSync(dir, { recursive: true })
  22. const head = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n')
  23. fs.writeFileSync(path.join(dir, `${name}.md`), `---\n${head}\n---\n\n${body}\n`)
  24. }
  25. const index = (dir, title, rows) => fs.writeFileSync(path.join(dir, '索引.md'), `# ${title}\n\n${rows.map(([n, d]) => `- [${n}](${n}.md) — ${d}`).join('\n')}\n`)
  26. const memoryText = async (agent, expectedCatalogs = 1) => {
  27. const assembly = await service('systemPrompt').assemble({ agent, scope: agent })
  28. assert.equal(assembly.contexts.filter(c => c.name === 'webnovel.memory').length, 0)
  29. await new Promise((resolve, reject) => {
  30. const timer = setTimeout(() => { off(); reject(new Error('catalog fixture turn timeout')) }, 10000)
  31. const off = ctx.on('agent/status', ({ agent: subject, status }) => {
  32. if (subject !== agent || status !== 'idle') return
  33. clearTimeout(timer); off(); resolve()
  34. })
  35. agent.followup(createUserMessage({ content: [{ type: 'text', text: '核对本次上下文。' }], source: { kind: 'user' } }))
  36. })
  37. const catalogs = Array.from({ length: agent.session.seq }, (_, i) => agent.session.eventAt(i))
  38. .filter(event => event.type === 'user/message' && event.data.source.plugin === 'webnovel-memory-catalog')
  39. assert.equal(catalogs.length, expectedCatalogs)
  40. return { rendered: catalogs.map(catalog => ({ text: catalog.data.content[0].text })) }
  41. }
  42. await check('记忆目录会话开始快照与选书目录', async () => {
  43. entry(authorDir, '冷开场', { 名称: '冷开场', 描述: '作者偏好 {{冷开场}} 直接入戏', 类: '文风', 标签: '[测试书]', 来源: '对谈', 生成模块: '模型' }, '正文不得进目录。')
  44. index(authorDir, '作者记忆索引', [['冷开场', '作者偏好 {{冷开场}} 直接入戏']])
  45. const created = await service('agents').create({ sessionId: 'loader-memory', meta: { cwd: workspace }, agentOptions })
  46. const agent = created.agent
  47. try {
  48. const first = await memoryText(agent)
  49. assert.equal(first.rendered.length, 1)
  50. const text = first.rendered[0].text
  51. assert.match(text, /【作者记忆目录】会话开始时/)
  52. assert.match(text, /- 冷开场 — 作者偏好 \{\{冷开场\}\} 直接入戏|类:文风/)
  53. assert.ok(text.includes(path.join(authorDir, '索引.md')), text)
  54. assert.doesNotMatch(text, /正文不得进目录/)
  55. const delegated = await service('agents').create({ parentAgent: agent, sessionId: 'loader-memory-child', meta: { cwd: workspace }, agentOptions })
  56. try { await memoryText(delegated.agent, 0) } finally { await delegated.dispose() }
  57. // 本轮新增记忆:同一 agent 的快照不变;普通查询与记忆写入都不改目录
  58. entry(authorDir, '后来才写', { 名称: '后来才写', 描述: '会话中途新增', 类: '决策', 标签: '[测试书]', 来源: '对谈', 生成模块: '模型' }, 'x')
  59. index(authorDir, '作者记忆索引', [['冷开场', '作者偏好 {{冷开场}} 直接入戏'], ['后来才写', '会话中途新增']])
  60. const recorded = await withTurn(agent, () => execute(agent, 'novel_record_memory', { bookId: 'loader-book', 名称: '工具写入', 描述: '经工具写入的条目', 类: '决策', 标签: ['测试书'], 来源: '对谈', 正文: 'x' }))
  61. assert.equal(recorded.value.ok, true, JSON.stringify(recorded))
  62. const again = await memoryText(agent)
  63. assert.equal(again.rendered[0].text, text)
  64. assert.doesNotMatch(text, /后来才写|工具写入/)
  65. // 选书:结果附本书目录(选书时快照);普通查询不附
  66. entry(path.join(book, '本书记忆'), '不写系统', { 名称: '不写系统', 类: '决策', 状态: '已确认', 来源: '定稿/卷01/0001-开篇.md', 裁决记录: '作者批准' }, '全书无系统面板。')
  67. index(path.join(book, '本书记忆'), '本书记忆索引', [['不写系统', '类:决策|来源:定稿/卷01/0001-开篇.md']])
  68. const selected = await withTurn(agent, () => execute(agent, 'novel_select_book', { bookId: 'loader-book' }))
  69. assert.equal(selected.value.ok, true, JSON.stringify(selected))
  70. assert.match(selected.value.记忆目录, /【本书记忆目录】选书时/)
  71. assert.match(selected.value.记忆目录, /- 不写系统 — (缺描述)|类:决策/)
  72. assert.ok(selected.value.记忆目录.includes(path.join(book, '本书记忆', '索引.md')))
  73. assert.doesNotMatch(selected.value.记忆目录, /全书无系统面板/)
  74. const status = await withTurn(agent, () => execute(agent, 'novel_get_story_status', { bookId: 'loader-book' }))
  75. assert.equal(status.value.ok, true)
  76. assert.equal(status.value.记忆目录, undefined)
  77. assert.equal((await memoryText(agent)).rendered[0].text, text)
  78. // 新会话:重新取得初始目录,含中途新增的条目
  79. const fresh = await service('agents').create({ sessionId: 'loader-memory-fresh', meta: { cwd: workspace }, agentOptions })
  80. try {
  81. const freshText = (await memoryText(fresh.agent)).rendered[0].text
  82. assert.match(freshText, /- 后来才写 — 会话中途新增/)
  83. assert.match(freshText, /- 工具写入 — 经工具写入的条目/)
  84. assert.notEqual(freshText, text)
  85. } finally { await fresh.dispose() }
  86. report.memoryCatalog = { nativeMessage: true, literalBraces: true, snapshotFixedWithinAgent: true, delegatedCatalogAbsent: true, selectBookCatalog: true, statusWithoutCatalog: true, freshAgentRereads: true, initialCatalogChars: Array.from(text).length }
  87. } finally { await created.dispose(); releaseAdapter() }
  88. })
  89. }