@modusensus/dsh-mneme 0.8.3 → 0.8.4

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 (193) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +718 -636
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +502 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +495 -316
  7. package/lib/api.js +1382 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4475 -4366
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +533 -510
  12. package/lib/dream/clustering.js +118 -118
  13. package/lib/dream/decisions.js +658 -658
  14. package/lib/dream/narratives.js +44 -0
  15. package/lib/dream/sleep.js +700 -700
  16. package/lib/dream.js +1319 -1216
  17. package/lib/embedding.js +177 -177
  18. package/lib/entities/extractor.js +249 -249
  19. package/lib/heat.js +142 -136
  20. package/lib/hot-memory.js +46 -46
  21. package/lib/index.js +515 -515
  22. package/lib/inject.js +299 -295
  23. package/lib/lang.js +202 -179
  24. package/lib/local-embedder.js +320 -320
  25. package/lib/mirror.js +198 -198
  26. package/lib/quality-filter.js +126 -126
  27. package/lib/recall-stats.js +103 -0
  28. package/lib/reranker.js +269 -269
  29. package/lib/runtime/adopt.js +176 -176
  30. package/lib/runtime/closure.js +172 -172
  31. package/lib/runtime/download.js +247 -247
  32. package/lib/runtime/layout.js +235 -235
  33. package/lib/runtime/loader.js +249 -249
  34. package/lib/runtime/model-fetch.js +361 -361
  35. package/lib/runtime/provision.js +237 -237
  36. package/lib/runtime/tarball.js +130 -130
  37. package/lib/runtime/verify.js +191 -191
  38. package/lib/scope.js +118 -118
  39. package/lib/search/adaptive.js +22 -22
  40. package/lib/search/bm25.js +96 -96
  41. package/lib/service.js +2171 -2087
  42. package/lib/settings.js +430 -420
  43. package/lib/store.js +2448 -2367
  44. package/lib/summarize.js +575 -449
  45. package/lib/tools.js +574 -574
  46. package/lib/vector-index.js +110 -110
  47. package/lib/version-check.js +122 -122
  48. package/package.json +109 -108
  49. package/runtime-manifest.json +596 -596
  50. package/scripts/benchmark-embed.js +201 -201
  51. package/scripts/benchmark-recall.js +199 -199
  52. package/scripts/benchmark-rerank.js +166 -166
  53. package/scripts/build-runtime-manifest.mjs +217 -217
  54. package/scripts/check-sync.js +42 -42
  55. package/scripts/e2e-dsh.js +220 -220
  56. package/scripts/mneme-runtime.mjs +277 -277
  57. package/scripts/stress-dsh.js +255 -255
  58. package/scripts/sync-lib.js +47 -47
  59. package/scripts/sync-test-badge.mjs +61 -61
  60. package/src/api-standalone.js +495 -316
  61. package/src/api.js +1382 -1316
  62. package/src/bootstrap.js +211 -0
  63. package/src/commands.js +95 -95
  64. package/src/config.js +533 -510
  65. package/src/dream/clustering.js +118 -118
  66. package/src/dream/decisions.js +658 -658
  67. package/src/dream/narratives.js +44 -0
  68. package/src/dream/sleep.js +700 -700
  69. package/src/dream.js +1319 -1216
  70. package/src/embedding.js +177 -177
  71. package/src/entities/extractor.js +249 -249
  72. package/src/heat.js +142 -136
  73. package/src/hot-memory.js +46 -46
  74. package/src/index.js +515 -515
  75. package/src/inject.js +299 -295
  76. package/src/lang.js +202 -179
  77. package/src/local-embedder.js +320 -320
  78. package/src/mirror.js +198 -198
  79. package/src/quality-filter.js +126 -126
  80. package/src/recall-stats.js +103 -0
  81. package/src/reranker.js +269 -269
  82. package/src/runtime/adopt.js +176 -176
  83. package/src/runtime/closure.js +172 -172
  84. package/src/runtime/download.js +247 -247
  85. package/src/runtime/layout.js +235 -235
  86. package/src/runtime/loader.js +249 -249
  87. package/src/runtime/model-fetch.js +361 -361
  88. package/src/runtime/provision.js +237 -237
  89. package/src/runtime/tarball.js +130 -130
  90. package/src/runtime/verify.js +191 -191
  91. package/src/scope.js +118 -118
  92. package/src/search/adaptive.js +22 -22
  93. package/src/search/bm25.js +96 -96
  94. package/src/service.js +2171 -2087
  95. package/src/settings.js +430 -420
  96. package/src/store.js +2448 -2367
  97. package/src/summarize.js +575 -449
  98. package/src/tools.js +574 -574
  99. package/src/vector-index.js +110 -110
  100. package/src/version-check.js +122 -122
  101. package/test/api-routes-gaps.test.js +218 -218
  102. package/test/api.test.js +1578 -1482
  103. package/test/audit.test.js +448 -448
  104. package/test/benchmark.test.js +94 -94
  105. package/test/bootstrap.test.js +220 -0
  106. package/test/client.test.js +799 -736
  107. package/test/clustering.test.js +100 -100
  108. package/test/commands.test.js +106 -106
  109. package/test/config.test.js +66 -66
  110. package/test/conflict-freeze.test.js +300 -300
  111. package/test/dream-archive-guardrail.test.js +116 -116
  112. package/test/dream-candidate-hybrid.test.js +281 -281
  113. package/test/dream-coverage-degrade.test.js +86 -86
  114. package/test/dream.test.js +1257 -1254
  115. package/test/embedding-ready.test.js +196 -196
  116. package/test/entities.test.js +547 -547
  117. package/test/entity-recall.test.js +179 -0
  118. package/test/epistemic.test.js +298 -298
  119. package/test/fnew-0112.test.js +311 -311
  120. package/test/fnew-03.test.js +422 -422
  121. package/test/graph-api.test.js +175 -175
  122. package/test/heat.test.js +175 -148
  123. package/test/helpers/dream-mock.js +82 -82
  124. package/test/helpers/peer-worker.mjs +25 -25
  125. package/test/helpers/runtime-source.js +60 -60
  126. package/test/helpers/tar-builder.js +80 -80
  127. package/test/hot-memory.test.js +145 -145
  128. package/test/inject-content-cap.test.js +89 -0
  129. package/test/inject-first-round-bm25.test.js +89 -89
  130. package/test/inject-heat.test.js +61 -0
  131. package/test/inject-rotation.test.js +161 -161
  132. package/test/inject.test.js +249 -249
  133. package/test/lang.test.js +87 -87
  134. package/test/lib-smoke.test.js +96 -96
  135. package/test/llm-audit.test.js +309 -309
  136. package/test/llm-message-source.test.js +177 -177
  137. package/test/local-embedder.test.js +249 -249
  138. package/test/mcp-stdio.test.js +385 -0
  139. package/test/mirror-dirty.test.js +424 -424
  140. package/test/mirror-edit-digest.test.js +187 -187
  141. package/test/mirror-generation.test.js +499 -499
  142. package/test/mirror.test.js +249 -249
  143. package/test/narrative-bars.test.js +162 -0
  144. package/test/peer-blockers.test.js +193 -193
  145. package/test/policy-epoch.test.js +259 -259
  146. package/test/quality-filter.test.js +186 -186
  147. package/test/reasoning-effort.test.js +746 -746
  148. package/test/recall-evals.test.js +235 -235
  149. package/test/recall-layer.test.js +326 -326
  150. package/test/recall-stats.test.js +117 -0
  151. package/test/receipt-chain.test.js +451 -451
  152. package/test/reflection.test.js +226 -226
  153. package/test/reindex-backfill.test.js +62 -62
  154. package/test/reranker-single-logit.test.js +73 -73
  155. package/test/reranker.test.js +197 -197
  156. package/test/resident-status.test.js +83 -0
  157. package/test/runtime-adopt.test.js +206 -206
  158. package/test/runtime-cli.test.js +89 -89
  159. package/test/runtime-closure.test.js +193 -193
  160. package/test/runtime-download.test.js +302 -302
  161. package/test/runtime-layout.test.js +163 -163
  162. package/test/runtime-loader.test.js +237 -237
  163. package/test/runtime-manifest.test.js +158 -158
  164. package/test/runtime-model-fetch.test.js +348 -348
  165. package/test/runtime-provision.test.js +101 -101
  166. package/test/runtime-tarball.test.js +81 -81
  167. package/test/runtime-verify.test.js +214 -214
  168. package/test/scope-candidates.test.js +239 -239
  169. package/test/scope-explicit.test.js +452 -452
  170. package/test/scope-retrieval.test.js +197 -197
  171. package/test/scope-storage.test.js +255 -255
  172. package/test/scope-strict.test.js +231 -231
  173. package/test/search-fusion.test.js +90 -90
  174. package/test/semantic.test.js +124 -124
  175. package/test/service-search.test.js +174 -174
  176. package/test/service.test.js +405 -405
  177. package/test/settings.test.js +261 -261
  178. package/test/skip-invalid-restore.test.js +262 -262
  179. package/test/sleep-action-set.test.js +422 -422
  180. package/test/sleep-heat.test.js +126 -125
  181. package/test/sleep-scheduler-rearm.test.js +89 -89
  182. package/test/sleep.test.js +373 -373
  183. package/test/standalone-api.test.js +544 -359
  184. package/test/store-vector-cache.test.js +98 -98
  185. package/test/store.test.js +383 -383
  186. package/test/stress.test.js +209 -209
  187. package/test/summarize.test.js +884 -575
  188. package/test/tools-memory-get-schema.test.js +65 -65
  189. package/test/tools.test.js +341 -341
  190. package/test/updated-at-semantics.test.js +113 -113
  191. package/test/vector-index.test.js +221 -221
  192. package/test/version-check.test.js +152 -152
  193. package/test/webServer-optional.test.js +82 -82
package/src/inject.js CHANGED
@@ -1,295 +1,299 @@
1
- import { createScopeResolver } from "./scope.js";
2
- import { createHotMemory } from "./hot-memory.js";
3
- import { STR, langOf } from "./lang.js";
4
-
5
- // Best-effort extraction of the current user's latest message text from the
6
- // live session, for semantic-first injection (Bug4). The system-prompt
7
- // interpolator renders synchronously, so this walks the already-materialized
8
- // session event log (same event shape summarize.js consumes) and returns the
9
- // most recent human message. Any failure degrades to "" — the injector then
10
- // falls back to the legacy rule-based pick, never breaking the render.
11
- function lastUserQuery(ctx) {
12
- try {
13
- const session = ctx?.agent?.session;
14
- // DSH ≥0.1.2-rc only exposes events via snapshotEvents(); older builds
15
- // still have the .events property, so fall back to it.
16
- const events = session?.snapshotEvents?.() ?? session?.events;
17
- if (!Array.isArray(events) || events.length === 0) return "";
18
- for (let i = events.length - 1; i >= 0; i--) {
19
- const event = events[i];
20
- if (event?.type !== "user/message") continue;
21
- const kind = event.data?.source?.kind;
22
- if (kind !== undefined && kind !== "user") continue;
23
- const parts = event.data?.content;
24
- if (!Array.isArray(parts) || parts.length === 0) continue;
25
- return parts
26
- .map((p) => (typeof p === "string" ? p : p?.text ?? ""))
27
- .filter(Boolean)
28
- .join("\n")
29
- .slice(0, 500);
30
- }
31
- } catch { /* session internals unavailable: degrade to no query */ }
32
- return "";
33
- }
34
-
35
- // Hot-memory round extraction (v0.5.0 1.3): pairs each user/message with the
36
- // next assistant reply from the materialized session log. Tolerates shapes
37
- // where assistant events carry a different type tag — anything whose payload
38
- // has content parts and is not a user message counts as a reply. Best-effort:
39
- // returns [] on any failure, and the hot block simply does not render.
40
- function extractRounds(ctx, maxRounds) {
41
- try {
42
- const session = ctx?.agent?.session;
43
- const events = session?.snapshotEvents?.() ?? session?.events;
44
- if (!Array.isArray(events) || events.length === 0) return [];
45
- const rounds = [];
46
- let pendingQuery = null;
47
- const textOf = (event) => {
48
- // user/message 的正文就在 data 上,而 assistant/message(以及 tool/result)的消息体
49
- // 嵌在 data.message 下 —— DSH 侧由 @deepseek-ai/dsh-session 的
50
- // assertMessageEventShape 一行写死:const message = type === "user/message" ? record : record?.["message"]。
51
- // 只读 data.content 会让每一轮的 response 恒为空(Issue #129)。
52
- // 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
53
- const parts = event?.data?.content ?? event?.data?.message?.content;
54
- if (!Array.isArray(parts)) return "";
55
- // 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
56
- // reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
57
- // 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
58
- // (字符串 part)照旧取用,无 type 字段的兜底视为正文。
59
- return parts
60
- .map((p) => {
61
- if (typeof p === "string") return p;
62
- if (p?.type && p.type !== "text") return "";
63
- return p?.text ?? "";
64
- })
65
- .filter(Boolean)
66
- .join("\n")
67
- .trim();
68
- };
69
- for (const event of events) {
70
- const kind = event?.data?.source?.kind;
71
- const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
72
- if (isUser) {
73
- if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
74
- pendingQuery = textOf(event).slice(0, 500);
75
- continue;
76
- }
77
- // Only assistant-originated events close a round; tool/system events
78
- // carrying text must not be mistaken for the model's reply.
79
- const isAssistant = typeof event?.type === "string" && event.type.includes("assistant")
80
- || kind === "assistant";
81
- const body = isAssistant ? textOf(event) : "";
82
- if (!body || !pendingQuery) continue;
83
- rounds.push({ query: pendingQuery, response: body.slice(0, 800) });
84
- pendingQuery = null;
85
- }
86
- if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
87
- return rounds.slice(-maxRounds);
88
- } catch {
89
- return [];
90
- }
91
- }
92
-
93
- export function createInjector(ctx, service, settings, config) {
94
- const language = langOf(config);
95
- const maxItems = config.maxInjectedItems ?? 5;
96
- const threshold = config.importanceThreshold ?? 3;
97
- // Issue #205:注入位跨轮轮换。rotationTurns = 最近 N 个「不同用户查询」轮次
98
- // 注入过的记忆本轮不再优先(0 = 关闭,保持既有行为)。历史按会话维护——
99
- // 新会话从零开始;同一查询的多次渲染(工具调用轮)视为同一轮,不推进窗口。
100
- const rotationTurns = Math.max(0, Math.floor(config.injectRotationTurns ?? 0));
101
- const rotationHistory = new Map(); // sessionId -> [{ query, ids: Set }]
102
- const ROTATION_HISTORY_MAX = 32; // 覆盖 max(20) 配置档仍有余量
103
-
104
- function recentInjectedIds(sessionId, query) {
105
- if (rotationTurns <= 0) return null;
106
- const deque = rotationHistory.get(sessionId);
107
- if (!deque || deque.length === 0) return null;
108
- // 只看「之前的轮次」:排除与当前查询相同的条目——同一查询的重复渲染
109
- // (工具调用轮)不该拿本轮自己的集合来转自己。
110
- const prior = query ? deque.filter((e) => e.query !== query) : deque;
111
- if (prior.length === 0) return null;
112
- const recent = new Set();
113
- for (const entry of prior.slice(-rotationTurns)) {
114
- for (const id of entry.ids) recent.add(id);
115
- }
116
- return recent.size > 0 ? recent : null;
117
- }
118
-
119
- function recordInjection(sessionId, query, candidates) {
120
- if (rotationTurns <= 0 || !query) return;
121
- const deque = rotationHistory.get(sessionId) ?? [];
122
- const ids = new Set(candidates.map((c) => c.id));
123
- const last = deque[deque.length - 1];
124
- if (last && last.query === query) {
125
- last.ids = ids; // 同一查询的重复渲染:覆盖本轮内容,不推进窗口
126
- } else {
127
- deque.push({ query, ids });
128
- if (deque.length > ROTATION_HISTORY_MAX) deque.shift();
129
- rotationHistory.set(sessionId, deque);
130
- }
131
- }
132
- // v0.8.0 A3(issue #17):注入路径的会话 scope 解析——strictScope 硬过滤
133
- // 需要。渲染 ctx 与工具 exec 同形(agent.session),解析器直接复用。
134
- // logger 透传:registry 反查失败时 warnOnce 才有出口。
135
- const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
136
-
137
- // Bug6: bound the injected memory block. Each entry's content is truncated to
138
- // MAX_CONTENT chars (trailing `…`); the whole block gets a MAX_BLOCK budget
139
- // and an entry that would exceed it collapses to its title only, so a long
140
- // memory can never push the injected context past a few thousand chars.
141
- const MAX_CONTENT = 300;
142
- const MAX_BLOCK = 1500;
143
-
144
- // Compressed injection (v0.5.0 2.1): a sleep-demoted row already carries its
145
- // summary in `content` with the original parked in `_full_content` — inject
146
- // the summary verbatim instead of re-truncating the (already short) text.
147
- // Regular long rows keep the hard truncate.
148
- function injectMemory(m, maxLength = MAX_CONTENT) {
149
- if (m?._full_content) return String(m.content ?? "");
150
- const text = String(m?.content ?? "");
151
- return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
152
- }
153
-
154
- // Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
155
- // DSH's interpolate() scans every injected section for `{{...}}` and throws
156
- // unless the variable name matches /^[a-z][a-z0-9_]*$/ — memory/profile
157
- // content carrying legal template syntax (docker `--format "{{.Server.Version}}"`,
158
- // Obsidian-style `{{hl|}}`, `{{挖空}}`, `{{关键词}}`) would hit an illegal
159
- // variable name and throw, crashing the whole turn. At the injection boundary
160
- // we escape every run of 2+ consecutive braces, inserting a `\` between each
161
- // pair, so no `{{`/`}}` substring survives into the prompt: `{{a}}` → `{\{a\}\}`,
162
- // and odd runs like `{{{a}}}` (which pair-wise escaping would leave with a
163
- // literal `{{`) are handled too. The text keeps its readable template form,
164
- // the transform is idempotent (escaped braces are single + `\`, never two
165
- // adjacent), and single braces pass through untouched — interpolate only
166
- // scans `{{`. Off via config.escapePromptVariables=false (default true).
167
- function escapePromptVars(text) {
168
- if (config.escapePromptVariables === false) return String(text);
169
- return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
170
- }
171
-
172
- // Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
173
- // the materialized event log on every render — stateless, so it survives
174
- // session switches and never persists anywhere.
175
- const hot = createHotMemory({
176
- maxRounds: config.hotMemoryRounds ?? 5,
177
- maxTokens: config.hotMemoryMaxTokens ?? 2000
178
- });
179
-
180
- function renderHotContext(ctx) {
181
- if (config.hotMemoryEnabled === false) return "";
182
- const rounds = extractRounds(ctx, config.hotMemoryRounds ?? 5);
183
- if (!rounds.length) return "";
184
- hot.clear();
185
- for (const r of rounds) hot.add(r);
186
- const body = hot.getContext();
187
- if (!body) return "";
188
- return escapePromptVars(STR.hotHeader[language](rounds.length, body));
189
- }
190
-
191
- function render(candidates) {
192
- if (!candidates.length) return "";
193
- const header = STR.memoryHeader[language];
194
- const lines = [header];
195
- let budget = MAX_BLOCK - header.length;
196
- for (const m of candidates) {
197
- // Epistemic trust (v0.4.5): when enabled, measured observations are
198
- // flagged so the agent can weigh them above guesses/opinions.
199
- const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
200
- ? STR.verified[language]
201
- : "";
202
- const title = STR.entryTitle[language](m.title, m.importance);
203
- const content = injectMemory(m);
204
- const full = STR.entryLine[language](m.type, verified, title, content);
205
- if (budget - full.length >= 0) {
206
- lines.push(full);
207
- budget -= full.length;
208
- } else {
209
- lines.push(`- [${m.type}] ${verified}${title}`);
210
- }
211
- }
212
- return escapePromptVars(lines.join("\n"));
213
- }
214
-
215
- // Bug4: the system-prompt render is synchronous, so the semantic query vector
216
- // must be prefetched asynchronously and cached for the next assembly. The
217
- // first render after a new user message may still fall back to the rule-based
218
- // pick; later assemblies in the same session reuse the cached vector. Bounded
219
- // cache (cap 8, drop oldest) so a long session never grows it unbounded.
220
- const QUERY_VECTOR_CACHE_MAX = 8;
221
- const queryVectorCache = new Map();
222
- let lastPrefetched = "";
223
-
224
- function prefetchQueryVector(query) {
225
- if (!query || query === lastPrefetched || queryVectorCache.has(query)) return;
226
- lastPrefetched = query;
227
- service.embedQuery(query).then((vec) => {
228
- if (Array.isArray(vec) && vec.length) {
229
- queryVectorCache.set(query, vec);
230
- if (queryVectorCache.size > QUERY_VECTOR_CACHE_MAX) {
231
- queryVectorCache.delete(queryVectorCache.keys().next().value);
232
- }
233
- }
234
- }).catch(() => { /* prefetch is best-effort */ });
235
- }
236
-
237
- // User profile + rules: injected ahead of the memory block because they are
238
- // always-relevant instructions the agent should follow every turn.
239
- function renderUserSettings() {
240
- const profile = settings.getProfile().trim();
241
- const rules = settings.getRules();
242
- if (!profile && !rules.length) return "";
243
- const lines = [STR.userSettingsHeader[language]];
244
- if (profile) lines.push(STR.profileLine[language](profile));
245
- for (const rule of rules) lines.push(STR.ruleLine[language](rule));
246
- return escapePromptVars(lines.join("\n"));
247
- }
248
-
249
- const disposers = [
250
- ctx.systemPrompt.context({
251
- name: "memory",
252
- order: 90,
253
- text: (ctx) => {
254
- // Bug4: pass the latest user query so injection prefers semantically
255
- // relevant memories; lastUserQuery is best-effort (empty → legacy).
256
- // The query vector is prefetched asynchronously (cached) because the
257
- // render itself must stay synchronous.
258
- const query = lastUserQuery(ctx);
259
- if (query) prefetchQueryVector(query);
260
- const queryVector = queryVectorCache.get(query);
261
- // v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤;
262
- // flag 关闭时解析器返回 null,注入行为不变)。
263
- const scope = resolveSessionScope(ctx);
264
- // Issue #205:跨轮轮换——最近 N 个查询轮次注入过的 id 本轮不再优先。
265
- const sessionId = ctx?.agent?.session?.id ?? "_";
266
- const rotate = recentInjectedIds(sessionId, query);
267
- // rotateWindow 随集合一起传入:候选池按 maxItems×(N+1) 扩容——窗口要的
268
- // 牌比既有 maxItems×2 池多时,轮换才有新鲜牌可换(#205 补测)。
269
- const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold, scope, rotate, rotateWindow: rotationTurns });
270
- recordInjection(sessionId, query, candidates);
271
- // Hot memory (v0.5.0 1.3) leads the single memory block: the agent
272
- // sees the short-term rounds first, then the cross-session recall —
273
- // the documented injection order 1→2. Folding it here (instead of a
274
- // separate context) keeps the prompt assembly stable at two blocks.
275
- const hotText = renderHotContext(ctx);
276
- const body = render(candidates);
277
- if (!hotText) return body;
278
- return body ? `${hotText}\n\n${body}` : hotText;
279
- }
280
- }),
281
- ctx.systemPrompt.context({
282
- name: "user-settings",
283
- order: 85,
284
- text: renderUserSettings
285
- })
286
- ];
287
-
288
- return () => {
289
- queryVectorCache.clear();
290
- rotationHistory.clear();
291
- for (const dispose of disposers) {
292
- if (typeof dispose === "function") dispose();
293
- }
294
- };
295
- }
1
+ import { createScopeResolver } from "./scope.js";
2
+ import { createHotMemory } from "./hot-memory.js";
3
+ import { STR, langOf } from "./lang.js";
4
+
5
+ // Best-effort extraction of the current user's latest message text from the
6
+ // live session, for semantic-first injection (Bug4). The system-prompt
7
+ // interpolator renders synchronously, so this walks the already-materialized
8
+ // session event log (same event shape summarize.js consumes) and returns the
9
+ // most recent human message. Any failure degrades to "" — the injector then
10
+ // falls back to the legacy rule-based pick, never breaking the render.
11
+ function lastUserQuery(ctx) {
12
+ try {
13
+ const session = ctx?.agent?.session;
14
+ // DSH ≥0.1.2-rc only exposes events via snapshotEvents(); older builds
15
+ // still have the .events property, so fall back to it.
16
+ const events = session?.snapshotEvents?.() ?? session?.events;
17
+ if (!Array.isArray(events) || events.length === 0) return "";
18
+ for (let i = events.length - 1; i >= 0; i--) {
19
+ const event = events[i];
20
+ if (event?.type !== "user/message") continue;
21
+ const kind = event.data?.source?.kind;
22
+ if (kind !== undefined && kind !== "user") continue;
23
+ const parts = event.data?.content;
24
+ if (!Array.isArray(parts) || parts.length === 0) continue;
25
+ return parts
26
+ .map((p) => (typeof p === "string" ? p : p?.text ?? ""))
27
+ .filter(Boolean)
28
+ .join("\n")
29
+ .slice(0, 500);
30
+ }
31
+ } catch { /* session internals unavailable: degrade to no query */ }
32
+ return "";
33
+ }
34
+
35
+ // Hot-memory round extraction (v0.5.0 1.3): pairs each user/message with the
36
+ // next assistant reply from the materialized session log. Tolerates shapes
37
+ // where assistant events carry a different type tag — anything whose payload
38
+ // has content parts and is not a user message counts as a reply. Best-effort:
39
+ // returns [] on any failure, and the hot block simply does not render.
40
+ function extractRounds(ctx, maxRounds) {
41
+ try {
42
+ const session = ctx?.agent?.session;
43
+ const events = session?.snapshotEvents?.() ?? session?.events;
44
+ if (!Array.isArray(events) || events.length === 0) return [];
45
+ const rounds = [];
46
+ let pendingQuery = null;
47
+ const textOf = (event) => {
48
+ // user/message 的正文就在 data 上,而 assistant/message(以及 tool/result)的消息体
49
+ // 嵌在 data.message 下 —— DSH 侧由 @deepseek-ai/dsh-session 的
50
+ // assertMessageEventShape 一行写死:const message = type === "user/message" ? record : record?.["message"]。
51
+ // 只读 data.content 会让每一轮的 response 恒为空(Issue #129)。
52
+ // 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
53
+ const parts = event?.data?.content ?? event?.data?.message?.content;
54
+ if (!Array.isArray(parts)) return "";
55
+ // 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
56
+ // reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
57
+ // 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
58
+ // (字符串 part)照旧取用,无 type 字段的兜底视为正文。
59
+ return parts
60
+ .map((p) => {
61
+ if (typeof p === "string") return p;
62
+ if (p?.type && p.type !== "text") return "";
63
+ return p?.text ?? "";
64
+ })
65
+ .filter(Boolean)
66
+ .join("\n")
67
+ .trim();
68
+ };
69
+ for (const event of events) {
70
+ const kind = event?.data?.source?.kind;
71
+ const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
72
+ if (isUser) {
73
+ if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
74
+ pendingQuery = textOf(event).slice(0, 500);
75
+ continue;
76
+ }
77
+ // Only assistant-originated events close a round; tool/system events
78
+ // carrying text must not be mistaken for the model's reply.
79
+ const isAssistant = typeof event?.type === "string" && event.type.includes("assistant")
80
+ || kind === "assistant";
81
+ const body = isAssistant ? textOf(event) : "";
82
+ if (!body || !pendingQuery) continue;
83
+ rounds.push({ query: pendingQuery, response: body.slice(0, 800) });
84
+ pendingQuery = null;
85
+ }
86
+ if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
87
+ return rounds.slice(-maxRounds);
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ export function createInjector(ctx, service, settings, config) {
94
+ const language = langOf(config);
95
+ const maxItems = config.maxInjectedItems ?? 5;
96
+ const threshold = config.importanceThreshold ?? 3;
97
+ // Issue #205:注入位跨轮轮换。rotationTurns = 最近 N 个「不同用户查询」轮次
98
+ // 注入过的记忆本轮不再优先(0 = 关闭,保持既有行为)。历史按会话维护——
99
+ // 新会话从零开始;同一查询的多次渲染(工具调用轮)视为同一轮,不推进窗口。
100
+ const rotationTurns = Math.max(0, Math.floor(config.injectRotationTurns ?? 0));
101
+ const rotationHistory = new Map(); // sessionId -> [{ query, ids: Set }]
102
+ const ROTATION_HISTORY_MAX = 32; // 覆盖 max(20) 配置档仍有余量
103
+
104
+ function recentInjectedIds(sessionId, query) {
105
+ if (rotationTurns <= 0) return null;
106
+ const deque = rotationHistory.get(sessionId);
107
+ if (!deque || deque.length === 0) return null;
108
+ // 只看「之前的轮次」:排除与当前查询相同的条目——同一查询的重复渲染
109
+ // (工具调用轮)不该拿本轮自己的集合来转自己。
110
+ const prior = query ? deque.filter((e) => e.query !== query) : deque;
111
+ if (prior.length === 0) return null;
112
+ const recent = new Set();
113
+ for (const entry of prior.slice(-rotationTurns)) {
114
+ for (const id of entry.ids) recent.add(id);
115
+ }
116
+ return recent.size > 0 ? recent : null;
117
+ }
118
+
119
+ function recordInjection(sessionId, query, candidates) {
120
+ if (rotationTurns <= 0 || !query) return;
121
+ const deque = rotationHistory.get(sessionId) ?? [];
122
+ const ids = new Set(candidates.map((c) => c.id));
123
+ const last = deque[deque.length - 1];
124
+ if (last && last.query === query) {
125
+ last.ids = ids; // 同一查询的重复渲染:覆盖本轮内容,不推进窗口
126
+ } else {
127
+ deque.push({ query, ids });
128
+ if (deque.length > ROTATION_HISTORY_MAX) deque.shift();
129
+ rotationHistory.set(sessionId, deque);
130
+ }
131
+ }
132
+ // v0.8.0 A3(issue #17):注入路径的会话 scope 解析——strictScope 硬过滤
133
+ // 需要。渲染 ctx 与工具 exec 同形(agent.session),解析器直接复用。
134
+ // logger 透传:registry 反查失败时 warnOnce 才有出口。
135
+ const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
136
+
137
+ // Bug6: bound the injected memory block. Each entry's content is truncated to
138
+ // injectContentMaxChars chars (issue #164①: configurable, was a hardcoded
139
+ // 300); a truncated entry carries a tail hint (limit / original length /
140
+ // memory_get id) so the cut is never silent — BUDGET_EXCEEDED principle, the
141
+ // agent can always fetch the full text. The whole block gets a block budget
142
+ // (scales up with the per-entry cap so raising the cap is not defeated by a
143
+ // stale 1500) and an entry that would exceed it collapses to its title only.
144
+ const maxContent = config.injectContentMaxChars ?? 300;
145
+ const MAX_BLOCK = Math.max(1500, maxContent + 600);
146
+
147
+ // Compressed injection (v0.5.0 2.1): a sleep-demoted row already carries its
148
+ // summary in `content` with the original parked in `_full_content` — inject
149
+ // the summary verbatim instead of re-truncating the (already short) text.
150
+ // Regular long rows keep the hard truncate.
151
+ function injectMemory(m, maxLength = maxContent) {
152
+ if (m?._full_content) return String(m.content ?? "");
153
+ const text = String(m?.content ?? "");
154
+ if (text.length <= maxLength) return text;
155
+ return `${text.slice(0, maxLength)}…${STR.truncatedHint[language](maxLength, text.length, m.id)}`;
156
+ }
157
+
158
+ // Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
159
+ // DSH's interpolate() scans every injected section for `{{...}}` and throws
160
+ // unless the variable name matches /^[a-z][a-z0-9_]*$/ — memory/profile
161
+ // content carrying legal template syntax (docker `--format "{{.Server.Version}}"`,
162
+ // Obsidian-style `{{hl|}}`, `{{挖空}}`, `{{关键词}}`) would hit an illegal
163
+ // variable name and throw, crashing the whole turn. At the injection boundary
164
+ // we escape every run of 2+ consecutive braces, inserting a `\` between each
165
+ // pair, so no `{{`/`}}` substring survives into the prompt: `{{a}}` → `{\{a\}\}`,
166
+ // and odd runs like `{{{a}}}` (which pair-wise escaping would leave with a
167
+ // literal `{{`) are handled too. The text keeps its readable template form,
168
+ // the transform is idempotent (escaped braces are single + `\`, never two
169
+ // adjacent), and single braces pass through untouched — interpolate only
170
+ // scans `{{`. Off via config.escapePromptVariables=false (default true).
171
+ function escapePromptVars(text) {
172
+ if (config.escapePromptVariables === false) return String(text);
173
+ return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
174
+ }
175
+
176
+ // Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
177
+ // the materialized event log on every render — stateless, so it survives
178
+ // session switches and never persists anywhere.
179
+ const hot = createHotMemory({
180
+ maxRounds: config.hotMemoryRounds ?? 5,
181
+ maxTokens: config.hotMemoryMaxTokens ?? 2000
182
+ });
183
+
184
+ function renderHotContext(ctx) {
185
+ if (config.hotMemoryEnabled === false) return "";
186
+ const rounds = extractRounds(ctx, config.hotMemoryRounds ?? 5);
187
+ if (!rounds.length) return "";
188
+ hot.clear();
189
+ for (const r of rounds) hot.add(r);
190
+ const body = hot.getContext();
191
+ if (!body) return "";
192
+ return escapePromptVars(STR.hotHeader[language](rounds.length, body));
193
+ }
194
+
195
+ function render(candidates) {
196
+ if (!candidates.length) return "";
197
+ const header = STR.memoryHeader[language];
198
+ const lines = [header];
199
+ let budget = MAX_BLOCK - header.length;
200
+ for (const m of candidates) {
201
+ // Epistemic trust (v0.4.5): when enabled, measured observations are
202
+ // flagged so the agent can weigh them above guesses/opinions.
203
+ const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
204
+ ? STR.verified[language]
205
+ : "";
206
+ const title = STR.entryTitle[language](m.title, m.importance);
207
+ const content = injectMemory(m);
208
+ const full = STR.entryLine[language](m.type, verified, title, content);
209
+ if (budget - full.length >= 0) {
210
+ lines.push(full);
211
+ budget -= full.length;
212
+ } else {
213
+ lines.push(`- [${m.type}] ${verified}${title}`);
214
+ }
215
+ }
216
+ return escapePromptVars(lines.join("\n"));
217
+ }
218
+
219
+ // Bug4: the system-prompt render is synchronous, so the semantic query vector
220
+ // must be prefetched asynchronously and cached for the next assembly. The
221
+ // first render after a new user message may still fall back to the rule-based
222
+ // pick; later assemblies in the same session reuse the cached vector. Bounded
223
+ // cache (cap 8, drop oldest) so a long session never grows it unbounded.
224
+ const QUERY_VECTOR_CACHE_MAX = 8;
225
+ const queryVectorCache = new Map();
226
+ let lastPrefetched = "";
227
+
228
+ function prefetchQueryVector(query) {
229
+ if (!query || query === lastPrefetched || queryVectorCache.has(query)) return;
230
+ lastPrefetched = query;
231
+ service.embedQuery(query).then((vec) => {
232
+ if (Array.isArray(vec) && vec.length) {
233
+ queryVectorCache.set(query, vec);
234
+ if (queryVectorCache.size > QUERY_VECTOR_CACHE_MAX) {
235
+ queryVectorCache.delete(queryVectorCache.keys().next().value);
236
+ }
237
+ }
238
+ }).catch(() => { /* prefetch is best-effort */ });
239
+ }
240
+
241
+ // User profile + rules: injected ahead of the memory block because they are
242
+ // always-relevant instructions the agent should follow every turn.
243
+ function renderUserSettings() {
244
+ const profile = settings.getProfile().trim();
245
+ const rules = settings.getRules();
246
+ if (!profile && !rules.length) return "";
247
+ const lines = [STR.userSettingsHeader[language]];
248
+ if (profile) lines.push(STR.profileLine[language](profile));
249
+ for (const rule of rules) lines.push(STR.ruleLine[language](rule));
250
+ return escapePromptVars(lines.join("\n"));
251
+ }
252
+
253
+ const disposers = [
254
+ ctx.systemPrompt.context({
255
+ name: "memory",
256
+ order: 90,
257
+ text: (ctx) => {
258
+ // Bug4: pass the latest user query so injection prefers semantically
259
+ // relevant memories; lastUserQuery is best-effort (empty → legacy).
260
+ // The query vector is prefetched asynchronously (cached) because the
261
+ // render itself must stay synchronous.
262
+ const query = lastUserQuery(ctx);
263
+ if (query) prefetchQueryVector(query);
264
+ const queryVector = queryVectorCache.get(query);
265
+ // v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤;
266
+ // flag 关闭时解析器返回 null,注入行为不变)。
267
+ const scope = resolveSessionScope(ctx);
268
+ // Issue #205:跨轮轮换——最近 N 个查询轮次注入过的 id 本轮不再优先。
269
+ const sessionId = ctx?.agent?.session?.id ?? "_";
270
+ const rotate = recentInjectedIds(sessionId, query);
271
+ // rotateWindow 随集合一起传入:候选池按 maxItems×(N+1) 扩容——窗口要的
272
+ // 牌比既有 maxItems×2 池多时,轮换才有新鲜牌可换(#205 补测)。
273
+ const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold, scope, rotate, rotateWindow: rotationTurns });
274
+ recordInjection(sessionId, query, candidates);
275
+ // Hot memory (v0.5.0 1.3) leads the single memory block: the agent
276
+ // sees the short-term rounds first, then the cross-session recall —
277
+ // the documented injection order 1→2. Folding it here (instead of a
278
+ // separate context) keeps the prompt assembly stable at two blocks.
279
+ const hotText = renderHotContext(ctx);
280
+ const body = render(candidates);
281
+ if (!hotText) return body;
282
+ return body ? `${hotText}\n\n${body}` : hotText;
283
+ }
284
+ }),
285
+ ctx.systemPrompt.context({
286
+ name: "user-settings",
287
+ order: 85,
288
+ text: renderUserSettings
289
+ })
290
+ ];
291
+
292
+ return () => {
293
+ queryVectorCache.clear();
294
+ rotationHistory.clear();
295
+ for (const dispose of disposers) {
296
+ if (typeof dispose === "function") dispose();
297
+ }
298
+ };
299
+ }