dsh-memu-memory 1.1.2 → 1.2.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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,93 @@
1
+ // Handwritten synthetic holdouts. Labels are used only after generation.
2
+ import crypto from 'node:crypto'
3
+ import { loadDataset, validateDialogueShape } from './repetition-eval.mjs'
4
+
5
+ const additions = [
6
+ ['station', 'zh-rp', '站台', '轻轻叹了口气', '许宁', '车票放在蓝色背包里',
7
+ '继续站台场景,用两三句话回答:许宁的车票在哪里?',
8
+ ['许宁'], ['车票', '蓝色', '背包']],
9
+ ['tea', 'zh-rp', '茶室', '嘴角勾起一抹笑意', '苏禾', '茶叶放在第二层抽屉里',
10
+ '继续茶室场景,苏禾找不到茶叶了,用两三句话告诉她位置。',
11
+ ['苏禾'], ['茶叶', '第二层', '抽屉']],
12
+ ['garden', 'zh-rp', '花园', '眼底闪过一丝复杂的神色', '陆遥', '温室钥匙挂在木门右侧',
13
+ '陆遥要去温室。继续场景,说明钥匙位置,两三句话即可。',
14
+ ['陆遥'], ['钥匙', '木门', '右侧']],
15
+ ['ferry', 'zh-rp', '渡口', '他沉默了片刻', '乔川', '最后一班船在晚上九点离港',
16
+ '乔川问最后一班船几点离港,继续场景并准确回答。',
17
+ ['乔川'], ['九点', '离港']],
18
+ ['names-long', 'names', '档案室', '她缓缓抬起了头', '诸葛明月', '档案装在灰色纸箱里',
19
+ '诸葛明月问档案在哪里。明确写出她的全名和档案位置。',
20
+ ['诸葛明月'], ['档案', '灰色', '纸箱']],
21
+ ['names-two', 'names', '诊所', '像是终于松了口气', '欧阳若雪', '南宫清和负责取药',
22
+ '欧阳若雪问谁负责取药。用完整姓名回答,不改变分工。',
23
+ ['欧阳若雪', '南宫清和'], ['南宫清和', '取药']],
24
+ ['quote-boundary', 'user-quote', '门口', '轻轻叹了口气', '林昭', '不要替我答应别人',
25
+ '请明确写出林昭,并逐字引用我的边界“不要替我答应别人”,不要改写引文。',
26
+ ['林昭', '不要替我答应别人'], ['不要替我答应别人']],
27
+ ['quote-night', 'user-quote', '夜谈', '眼底闪过一丝复杂的神色', '沈青', '把窗留一条缝',
28
+ '沈青问我刚才对窗户说了什么。请写出沈青并逐字引用“把窗留一条缝”。',
29
+ ['沈青', '把窗留一条缝'], ['把窗留一条缝']],
30
+ ['agreement-time', 'agreement', '日程', '这一点很重要', '程安', '每周三晚上八点一起复盘',
31
+ '请写出程安并逐字复述约定“每周三晚上八点一起复盘”,不要变更时间。',
32
+ ['程安', '每周三晚上八点一起复盘'], ['每周三', '八点', '复盘']],
33
+ ['agreement-limit', 'agreement', '工作台', '我会先检查入口再检查边界', '顾辰', '绝不能对生产库直接执行迁移',
34
+ '请写出顾辰,并逐字重复安全约束“绝不能对生产库直接执行迁移”。',
35
+ ['顾辰', '绝不能对生产库直接执行迁移'], ['绝不能', '生产库', '迁移']],
36
+ ['english-key', 'en', 'library', 'As you can see', 'Mara', 'The brass key is inside the green drawer',
37
+ 'In two sentences, name Mara and explain exactly where the brass key is.',
38
+ ['Mara'], ['brass', 'key', 'green', 'drawer']],
39
+ ['english-plan', 'en', 'meeting', 'A quiet sense of dread settled over the room', 'Evan', 'The review starts on Friday at nine',
40
+ 'Answer Evan in English: when does the review start? Keep the day and time.',
41
+ ['Evan'], ['Friday', 'nine']],
42
+ ['code-api', 'code', '代码评审', '这件事情需要格外注意', 'parseConfig', 'parseConfig 必须保留 ReadOnlyMap 返回类型',
43
+ '解释 parseConfig 的返回类型,逐字保留 parseConfig 和 ReadOnlyMap,简短回答。',
44
+ ['parseConfig', 'ReadOnlyMap'], ['parseConfig', 'ReadOnlyMap']],
45
+ ['code-port', 'code', '配置审查', '我会先检查入口再检查边界', 'config.port', 'config.port 的默认值是 8080',
46
+ 'config.port 默认值是什么?给出精确标识符和数值,简短回答。',
47
+ ['config.port', '8080'], ['config.port', '8080']],
48
+ ]
49
+
50
+ export function loadAbDataset() {
51
+ const base = loadDataset()
52
+ const dialogues = [...base.dialogues]
53
+ for (const [id, category, scene, filler, name, fact, request, necessary, requiredTerms] of additions) {
54
+ const english = category === 'en'
55
+ const target = english ? `${name}: ${fact}.` : `${name}:${fact}。`
56
+ const dialogue = {
57
+ schemaVersion: 1,
58
+ kind: 'repetition-eval-dialogue',
59
+ draft: true,
60
+ notGold: true,
61
+ window: 4,
62
+ id: `ab-${id}`,
63
+ pairId: `ab-${id}`,
64
+ category,
65
+ condition: 'baseline',
66
+ description: 'Synthetic holdout: wording repetition versus required information.',
67
+ turns: [
68
+ { role: 'user', text: english ? `Scene: ${scene}. Remember this established fact: ${fact}.` : `场景是${scene}。已确认事实:${fact}。` },
69
+ { role: 'assistant', text: english ? `${filler}. ${name} knows: ${fact}.` : `${name}${filler},记得${fact}。` },
70
+ { role: 'user', text: english ? 'Continue, preserving the established fact.' : '继续,不改变既定事实。' },
71
+ { role: 'assistant', text: english ? `${filler}. ${name} confirms: ${fact}.` : `${name}${filler},再次确认${fact}。` },
72
+ { role: 'user', text: request },
73
+ { role: 'assistant', text: target },
74
+ ],
75
+ labels: {
76
+ necessaryPhrases: necessary.map(text => ({
77
+ text,
78
+ category: category === 'code' ? 'code-identifier' : category === 'agreement' ? 'agreement' : category === 'user-quote' ? 'user-quote' : 'name',
79
+ turn: 5,
80
+ })),
81
+ facts: [{ id: `${id}-fact`, turn: 5, requiredTerms }],
82
+ },
83
+ }
84
+ validateDialogueShape(dialogue, dialogue.id)
85
+ dialogues.push(dialogue)
86
+ }
87
+ return {
88
+ ...base,
89
+ name: 'repetition-ab-20-synthetic-scenarios',
90
+ sha256: crypto.createHash('sha256').update(JSON.stringify(dialogues)).digest('hex'),
91
+ dialogues,
92
+ }
93
+ }
@@ -0,0 +1,314 @@
1
+ // Historical W8-B detector snapshot, EVALUATION ONLY.
2
+ // Retained to reproduce the W8 A/B evidence; no plugin runtime imports this file.
3
+ // This is not a shipped or supported production feature.
4
+ //
5
+ // Pure module: it never reads files, the network, state, or a real memory store.
6
+ // The caller passes the conversation's earlier assistant texts (normally taken from
7
+ // agent.session.deriveMessages(), not decision.messages) and receives either one
8
+ // bounded plugin-message body or an empty string.
9
+ //
10
+ // The hint is advisory. It is delivered only as a plugin message, never written
11
+ // into the system prompt, and a model that ignores it is not retried or blocked.
12
+ //
13
+ // Gate: only Chinese-dominant prose is considered (Han ratio >= 0.5 after code is
14
+ // masked). Code fences and inline code are masked before the ratio is taken;
15
+ // English-only or code-heavy conversations emit nothing. Only Han phrases are
16
+ // extracted: Latin phrases are not part of this feature.
17
+
18
+ import { estTokens } from '../lib/budget.js'
19
+
20
+ const BACKTICK = String.fromCharCode(96)
21
+ const HAN_RUN_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/gu
22
+ const HAN_CHAR_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u
23
+ const CODE_FENCE = new RegExp(BACKTICK.repeat(3) + '[^]*?' + BACKTICK.repeat(3), 'g')
24
+ const INLINE_CODE = new RegExp(BACKTICK + '[^' + BACKTICK + '\r\n]*' + BACKTICK, 'g')
25
+ const CONTROL_OR_BIDI = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g
26
+ const UNSAFE_TAG_CHARS = new RegExp('[<>' + BACKTICK + ']', 'g')
27
+ // Secrets, URLs, e-mail addresses and long digit runs never make it into a hint:
28
+ // they are masked before extraction and the sanitized phrase is re-checked.
29
+ const SENSITIVE = /(?:https?:\/\/|www\.)[\w\-.:/?=&%#@~+]+|[\w.+-]+@[\w-]+\.[A-Za-z]{2,}|sk-[A-Za-z0-9_-]{6,}|Bearer\s+[A-Za-z0-9._-]{8,}|[A-Za-z0-9+/]{32,}={0,2}|\d{6,}/gi
30
+
31
+ // A paraphrase may change wording, but not a date, a number, a promise, or a
32
+ // prohibition. Spans carrying those are necessary information, not repetition
33
+ // candidates. This snapshot keeps the historical guard: the closing sentence of the hint
34
+ // asks the model to preserve facts, which is advice, not a filter. Deliberately
35
+ // does NOT match bare "必须": ordinary prose such as "我们必须把话说清楚" is fair
36
+ // game for a wording hint and existing extractor tests rely on it.
37
+ const NECESSARY_PATTERNS = [
38
+ /(?:周|星期|礼拜)[一二三四五六日天]/u,
39
+ /(?:今|明|昨|后|前)天|(?:今|明|昨|后)晚/u,
40
+ /(?:早上|上午|中午|下午|晚上|凌晨|夜里|半夜)/u,
41
+ /(?:每(?:天|日|周|月|年|晚|早))/u,
42
+ /(?:[0-90-9]+|[一二三四五六七八九十百千万两半]+)(?:点|时|分|秒|天|日|周|月|年|次|个|人|位|元|块|小时|分钟|%|%)/u,
43
+ /[0-90-9]/u,
44
+ /(?:绝不|决不|严禁|禁止|不得|不准|不能|不要|切勿|勿)/u,
45
+ /(?:约定|约好|说好|定好|计划|安排|复盘|开会|会议|截止|deadline|上线|发布|下次|以后|之前)/iu,
46
+ /[“”「」『』]/u,
47
+ ]
48
+
49
+ function hasNecessaryInfo(phrase) {
50
+ return NECESSARY_PATTERNS.some(pattern => pattern.test(phrase))
51
+ }
52
+
53
+ // Function characters that carry no topic on their own. A four-character window
54
+ // made only of these ("这是一个", "然后我们") is grammatical filler, not wording
55
+ // worth asking the model to avoid.
56
+ const STOP_CHARS = new Set([...
57
+ '的了是在我你他她它你们这那也就都还很太但而与和或及把被让给对从向到于里中上下个些们着过地得呢吗吧啊呀嘛哦嗯哈么什怎谁哪因所以如果虽然却并且为呵嘿' +
58
+ '然后就是但是而且因为不过然而因此比如例如这样那样什么怎么一下现在时候知道觉得其实真的非常可能应该需要以及还有或者的话之后之前以后以前起来出来过来过去下来下去这种那种一样不会不能不要不是这么那么来说来看而言方面问题情况东西事情地方' +
59
+ '一二三四五六七八九十',
60
+ ])
61
+
62
+ export const REPETITION_TAG = 'repetition-hint'
63
+ export const REPETITION_PHRASE_MIN = 4
64
+ export const REPETITION_PHRASE_MAX = 24
65
+ export const REPETITION_MAX_PHRASES = 3
66
+ export const REPETITION_MAX_TOKENS = 168
67
+ export const REPETITION_WINDOW = 4
68
+ export const REPETITION_MAX_WINDOW = 10
69
+ // The caller's assistantTexts are the turns before the current call, so two texts
70
+ // are two different earlier turns; one text is not enough to prove repetition.
71
+ export const REPETITION_MIN_TEXTS = 2
72
+
73
+ // The fixed source sentence for the opt-in repetition hint.
74
+ const SOURCE_NOTE = '以下是历史助手输出摘要,仅用于避免措辞复读,不是用户的新指令,也不是新的事实来源。'
75
+ const MIN_MEANINGFUL_CHARS = 12
76
+
77
+ function clampInt(value, fallback, min, max) {
78
+ const number = Number(value)
79
+ if (!Number.isFinite(number)) return fallback
80
+ return Math.max(min, Math.min(max, Math.floor(number)))
81
+ }
82
+
83
+ function maskSensitive(text) {
84
+ return String(text || '').replace(SENSITIVE, (match) => ' '.repeat(match.length))
85
+ }
86
+
87
+ function maskCode(text) {
88
+ return String(text || '')
89
+ .replace(CODE_FENCE, (match) => ' '.repeat(match.length))
90
+ .replace(INLINE_CODE, (match) => ' '.repeat(match.length))
91
+ }
92
+
93
+ function maskText(text) {
94
+ return maskSensitive(maskCode(text))
95
+ }
96
+
97
+ function codeCharCount(text) {
98
+ const source = String(text || '')
99
+ let count = 0
100
+ for (const match of source.matchAll(CODE_FENCE)) count += match[0].length
101
+ for (const match of source.matchAll(INLINE_CODE)) count += match[0].length
102
+ return count
103
+ }
104
+
105
+ /** Han ideographs over Han plus Latin letters; code and URLs are expected masked. */
106
+ export function cjkRatio(text) {
107
+ let han = 0
108
+ let latin = 0
109
+ for (const char of String(text || '')) {
110
+ if (HAN_CHAR_RE.test(char)) han += 1
111
+ else if (/[A-Za-z]/.test(char)) latin += 1
112
+ }
113
+ const total = han + latin
114
+ return total === 0 ? 0 : han / total
115
+ }
116
+
117
+ function cjkMeaningfulCount(phrase) {
118
+ const chars = [...phrase].filter((char) => HAN_CHAR_RE.test(char))
119
+ const distinct = new Set(chars)
120
+ if (distinct.size <= 1) return 0
121
+ let meaningful = 0
122
+ for (const char of distinct) if (!STOP_CHARS.has(char)) meaningful += 1
123
+ return meaningful
124
+ }
125
+
126
+ function hasSensitive(phrase) {
127
+ SENSITIVE.lastIndex = 0
128
+ return SENSITIVE.test(phrase)
129
+ }
130
+
131
+ function sanitizePhrase(value) {
132
+ return String(value || '')
133
+ .normalize('NFKC')
134
+ .replace(CONTROL_OR_BIDI, '')
135
+ .replace(UNSAFE_TAG_CHARS, '')
136
+ .replace(/\s+/g, ' ')
137
+ .trim()
138
+ }
139
+
140
+ function isValidPhrase(phrase) {
141
+ if (phrase.length < REPETITION_PHRASE_MIN || phrase.length > REPETITION_PHRASE_MAX) return false
142
+ if (hasSensitive(phrase)) return false
143
+ const han = [...phrase].filter((char) => HAN_CHAR_RE.test(char)).length
144
+ return han >= REPETITION_PHRASE_MIN && cjkMeaningfulCount(phrase) >= 2
145
+ }
146
+
147
+ function phraseKey(phrase) {
148
+ return phrase.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
149
+ }
150
+
151
+ function dedupeCandidates(candidates) {
152
+ const seen = new Set()
153
+ const unique = []
154
+ for (const candidate of candidates) {
155
+ const key = phraseKey(candidate.phrase)
156
+ if (!key || seen.has(key)) continue
157
+ seen.add(key)
158
+ unique.push({ ...candidate, key })
159
+ }
160
+ // A shorter phrase already contained in a longer candidate adds no information
161
+ // and makes the hint read as a list of fragments rather than wording.
162
+ return unique.filter((candidate, index) => !unique.some((other, otherIndex) =>
163
+ otherIndex !== index && other.key.length > candidate.key.length && other.key.includes(candidate.key)))
164
+ }
165
+
166
+ function priorCjkCount(priorTexts, slice) {
167
+ let count = 0
168
+ for (const prior of priorTexts) if (prior.includes(slice)) count += 1
169
+ return count
170
+ }
171
+
172
+ /**
173
+ * Find repeated Han spans in one masked assistant text that also appeared in its
174
+ * earlier assistant texts. The prior list never includes the current text, so a
175
+ * phrase repeated inside one turn alone is not a cross-turn repetition.
176
+ */
177
+ function findRepeatedPhrases(text, priorTexts) {
178
+ const events = []
179
+ const source = String(text || '')
180
+ for (const match of source.matchAll(HAN_RUN_RE)) {
181
+ const run = match[0]
182
+ let index = 0
183
+ while (index + REPETITION_PHRASE_MIN <= run.length) {
184
+ const seed = run.slice(index, index + REPETITION_PHRASE_MIN)
185
+ if (priorCjkCount(priorTexts, seed) < 1) {
186
+ index += 1
187
+ continue
188
+ }
189
+ let end = index + REPETITION_PHRASE_MIN
190
+ const limit = Math.min(run.length, index + REPETITION_PHRASE_MAX)
191
+ while (end < limit && priorCjkCount(priorTexts, run.slice(index, end + 1)) >= 1) end += 1
192
+ events.push({ phrase: run.slice(index, end) })
193
+ index = end
194
+ }
195
+ }
196
+ return events
197
+ }
198
+
199
+ function collectCandidates(texts) {
200
+ const masked = texts.map(maskText)
201
+ const found = []
202
+ // The loop starts at the second text: the caller already excluded the current
203
+ // turn, so with two texts the phrase must appear in both earlier turns.
204
+ for (let index = 1; index < masked.length; index += 1) {
205
+ for (const event of findRepeatedPhrases(masked[index], masked.slice(0, index))) {
206
+ const phrase = sanitizePhrase(event.phrase)
207
+ if (!isValidPhrase(phrase) || hasNecessaryInfo(phrase)) continue
208
+ found.push({ phrase })
209
+ }
210
+ }
211
+ return dedupeCandidates(found)
212
+ }
213
+
214
+ /** Render the fixed tag and fixed explanation around already-sanitized phrases. */
215
+ export function renderRepetitionHint(phrases) {
216
+ const list = (Array.isArray(phrases) ? phrases : [])
217
+ .filter((phrase) => typeof phrase === 'string' && phrase)
218
+ .map(sanitizePhrase)
219
+ .filter((phrase) => isValidPhrase(phrase) && !hasNecessaryInfo(phrase))
220
+ if (list.length === 0) return ''
221
+ return [
222
+ '<' + REPETITION_TAG + '>',
223
+ '最近反复出现的措辞:' + list.join('、') + '。请换一种说法;保留人物姓名、用户原话、约定和事实。',
224
+ SOURCE_NOTE,
225
+ '</' + REPETITION_TAG + '>',
226
+ ].join('\n')
227
+ }
228
+
229
+ /**
230
+ * Extract only assistant prose from a host message list, in time order.
231
+ * Text may be a plain string or an array of content blocks; tool-call and other
232
+ * non-text blocks are ignored, and empty texts are dropped. The input is never
233
+ * mutated: history from agent.session.deriveMessages() arrives frozen.
234
+ */
235
+ export function extractAssistantTexts(messages) {
236
+ const texts = []
237
+ if (!Array.isArray(messages)) return texts
238
+ for (const message of messages) {
239
+ if (!message || message.role !== 'assistant') continue
240
+ let text = ''
241
+ if (typeof message.content === 'string') {
242
+ text = message.content
243
+ } else if (Array.isArray(message.content)) {
244
+ const parts = []
245
+ for (const block of message.content) {
246
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
247
+ }
248
+ text = parts.join('\n')
249
+ }
250
+ const trimmed = String(text || '').trim()
251
+ if (trimmed) texts.push(trimmed)
252
+ }
253
+ return texts
254
+ }
255
+
256
+ /**
257
+ * Build one bounded <repetition-hint> body, or '' when there is nothing safe to
258
+ * say. Two earlier assistant texts are needed: the phrase must appear in both
259
+ * earlier turns. Phrases come from the most recent window (default 4, max 10),
260
+ * are deduplicated, ordered by first appearance, and admitted greedily only while
261
+ * the whole hint stays within maxTokens (estimated with lib/budget.js; hard
262
+ * capped at 168).
263
+ */
264
+ export function buildRepetitionHint(options = {}) {
265
+ try {
266
+ const raw = Array.isArray(options.assistantTexts) ? options.assistantTexts : []
267
+ const texts = raw.map((text) => String(text || '').trim()).filter(Boolean)
268
+ if (texts.length < REPETITION_MIN_TEXTS) return ''
269
+
270
+ const window = clampInt(options.window, REPETITION_WINDOW, 1, REPETITION_MAX_WINDOW)
271
+ const recent = texts.slice(-window)
272
+ if (recent.length < REPETITION_MIN_TEXTS) return ''
273
+
274
+ const totalChars = recent.reduce((sum, text) => sum + text.length, 0)
275
+ if (totalChars === 0) return ''
276
+ const codeChars = recent.reduce((sum, text) => sum + codeCharCount(text), 0)
277
+ // A code-heavy message may still contain Chinese prose; masking the code is not
278
+ // enough to call the exchange Chinese-dominant, so half-or-more code wins.
279
+ if (codeChars / totalChars >= 0.5) return ''
280
+
281
+ const masked = recent.map(maskText)
282
+ let meaningful = 0
283
+ for (const text of masked) {
284
+ for (const char of text) {
285
+ if (HAN_CHAR_RE.test(char) || /[A-Za-z]/.test(char)) meaningful += 1
286
+ }
287
+ }
288
+ if (meaningful < MIN_MEANINGFUL_CHARS) return ''
289
+ if (cjkRatio(masked.join('\n')) < 0.5) return ''
290
+
291
+ const maxPhrases = clampInt(options.maxPhrases, REPETITION_MAX_PHRASES, 1, REPETITION_MAX_PHRASES)
292
+ const maxTokens = clampInt(options.maxTokens, REPETITION_MAX_TOKENS, 1, REPETITION_MAX_TOKENS)
293
+
294
+ const candidates = collectCandidates(recent)
295
+ if (candidates.length === 0) return ''
296
+
297
+ const accepted = []
298
+ for (const candidate of candidates) {
299
+ if (accepted.length >= maxPhrases) break
300
+ const trial = renderRepetitionHint([...accepted, candidate.phrase])
301
+ if (estTokens(trial) <= maxTokens) accepted.push(candidate.phrase)
302
+ }
303
+ if (accepted.length === 0) return ''
304
+ return renderRepetitionHint(accepted)
305
+ } catch {
306
+ // The hint is optional. Any malformed input becomes silence, not a failed turn.
307
+ return ''
308
+ }
309
+ }
310
+
311
+ /** Convenience wrapper for a host message list. */
312
+ export function repetitionHintForMessages(messages, options = {}) {
313
+ return buildRepetitionHint({ ...options, assistantTexts: extractAssistantTexts(messages) })
314
+ }