dsh-memsys 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -71,16 +71,22 @@ node scripts/live-check.mjs --case tools --profile memsys # 真机:插件
71
71
  - **设置 →「记忆系统」**:`auto_inject` 开关 + 当前数据目录;开关改完**立即生效**(重挂/撤下 system prompt 里的记忆段),刷新后仍在
72
72
  - 面板与设置页共用数据口 `/dsh-memsys/api/*`(loopback + 同源校验;跨源 403)
73
73
 
74
- **设置优先级**:`<data_dir>/ui-config.json`(存在且合法)> profile 的 `config.auto_inject` > 默认 `false`。
75
- `/api/summary` 会返回 `auto_inject_source`,告诉你这个值是从哪来的(`ui-config` / `profile` / `default`)。
74
+ **设置优先级**:`<data_dir>/ui-config.json`(存在且合法)> profile 的 `config` > 默认全开。
75
+ `/api/summary` 会返回 `modules` / `modules_source`,告诉你每个开关的当前值和来源(`ui-config` / `profile` / `default`)。
76
+
77
+ **注意生效时机**:自动注入、写入工具**改完立即生效**;读取工具 / 做梦 / 项目 / 待确认惦记 / 信息流是**下次启动生效**(工具在启动时一次性注册,宿主没有注销接口)。
76
78
 
77
79
  **headless 不受影响**:没有 `webServer` 时那段注册不激活,浏览器半也不加载。
78
80
 
79
- ## 状态
81
+ ## 状态(0.2.1)
80
82
 
81
- 已实现并在真机验证**读**与**写**(`dispatch=ok` 返回真实命中、`record=ok` 写入候选与记忆);**界面**已落地并有 7 条测试,但"真机 GUI 里三个 slot 真的画出来"要装进 profile 目测才算完。
83
+ - **读**:真机 `dispatch=ok`,`direct_hit_count: 2` 返回真实命中
84
+ - **写**:真机 `record=ok`,库内落 candidate + memory,`origin.author_kind=model`、`call_id` 与宿主记录一致
85
+ - **注入**:`compileRound` 已接进运行时,section 文本是**当轮编译出来的记忆目录**(`text` 为函数,每步 `assemble()` 重取);无 tokenizer/H 时退回 tools-only,不假装投递(`delivery=unknown`)
86
+ - **界面**:左侧栏「记忆」入口 + 面板(三数字 + 最近 5 条)+ 设置页七行模块开关
87
+ - **测试**:356 项全绿
82
88
 
83
- 仍有 4 条设计条款未达标:**混版分页测试**(C3,代码已接 `stale_cursor`)、**空分组回执边界**(C1)、**证据原文分片续页**(C4)、以及 `L6` 的"逐项重放 relations 内容"(现在只核到内容 hash + 关联)。逐条见文末"已知边界",不粉饰。
89
+ **仍不达标**:`L6` 只核到内容 hash 与关联,**没有逐项重放 relations 内容**;真实旧库尚未导入;生产 cutover 未跑;`feed` 生产 provider 未接。见文末"已知边界"。
84
90
 
85
91
  MIT License。
86
92
 
package/client.js CHANGED
@@ -49,6 +49,17 @@ window.__ModuleLoader__.load({
49
49
 
50
50
  const SOURCE_LABEL = { 'ui-config': '设置文件', profile: 'profile 配置', default: '默认值' };
51
51
 
52
+ // key, 名字, 一句人话, 生效时机(别糊成"立即生效",四个模块真的要重启)
53
+ const MODULE_ROWS = [
54
+ ['context', '自动注入', '每轮把记忆摘要注入系统提示;关掉后只能手动查', '改完立即生效'],
55
+ ['read', '读取工具', '关掉后查询类工具(get / search / review)不可用', '下次启动生效'],
56
+ ['write', '写入工具', '关掉后不再记录新记忆,候选也收', '改完立即生效'],
57
+ ['dream', '做梦', '关掉后不浮现、也不能记录梦', '下次启动生效'],
58
+ ['projects', '项目', '关掉后项目视角不可用', '下次启动生效'],
59
+ ['due', '待确认惦记', '关掉后不提醒到期的惦记,也不能确认或搁置', '下次启动生效'],
60
+ ['feed', '信息流', '关掉后不产出信息流候选', '下次启动生效'],
61
+ ];
62
+
52
63
  function MemorySettings() {
53
64
  const [state, setState] = React.useState(null);
54
65
  const [note, setNote] = React.useState('');
@@ -58,36 +69,52 @@ window.__ModuleLoader__.load({
58
69
  .then(setState)
59
70
  .catch(() => setState({ error: true }));
60
71
  }, []);
61
- const save = (value) => {
62
- const previous = state?.auto_inject === true;
63
- setState((current) => ({ ...(current ?? {}), auto_inject: value }));
72
+ const modules = state?.modules ?? {};
73
+ const save = (key, value) => {
74
+ const previous = modules[key] !== false;
75
+ setState((current) => ({ ...(current ?? {}), modules: { ...(current?.modules ?? {}), [key]: value } }));
64
76
  setNote('保存中…');
65
77
  fetch(CONFIG, {
66
78
  method: 'POST',
67
79
  headers: { 'content-type': 'application/json' },
68
- body: JSON.stringify({ auto_inject: value }),
80
+ body: JSON.stringify({ modules: { [key]: value } }),
69
81
  })
70
- .then((response) => (response.ok ? setNote('已保存') : Promise.reject(new Error(String(response.status)))))
82
+ .then((response) =>
83
+ response.ok
84
+ ? response.json().then((body) => setState((current) => ({ ...(current ?? {}), ...body })))
85
+ : Promise.reject(new Error(String(response.status))),
86
+ )
87
+ .then(() => setNote('已保存'))
71
88
  .catch(() => {
72
- setState((current) => ({ ...(current ?? {}), auto_inject: previous }));
89
+ setState((current) => ({ ...(current ?? {}), modules: { ...(current?.modules ?? {}), [key]: previous } }));
73
90
  setNote('保存失败');
74
91
  });
75
92
  };
93
+ const disabled = !state || state.error === true;
76
94
  return h(
77
95
  'div',
78
96
  { style: { padding: '8px 0', lineHeight: 1.7 } },
79
- h('label', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
80
- h('input', {
81
- type: 'checkbox',
82
- checked: state?.auto_inject === true,
83
- disabled: !state || state.error === true,
84
- onChange: (event) => save(event.target.checked),
85
- }),
86
- '自动注入记忆',
97
+ h('p', { style: { margin: '0 0 4px', opacity: 0.75 } }, '按模块开关,默认全开。关掉哪一项,那一项就不再工作。'),
98
+ ...MODULE_ROWS.map(([key, label, hint, timing]) =>
99
+ h(
100
+ 'label',
101
+ { key, style: { display: 'block', margin: '6px 0' } },
102
+ h('span', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
103
+ h('input', {
104
+ type: 'checkbox',
105
+ checked: modules[key] !== false,
106
+ disabled,
107
+ onChange: (event) => save(key, event.target.checked),
108
+ }),
109
+ label,
110
+ ),
111
+ h('span', { style: { display: 'block', marginLeft: 24, opacity: 0.7, fontSize: '0.92em' } },
112
+ `${hint}(${timing})`),
113
+ ),
87
114
  ),
88
- h('p', { style: { opacity: 0.75, margin: '6px 0 0' } }, `数据目录:${state?.data_dir ?? '未配置'}`),
115
+ h('p', { style: { opacity: 0.75, margin: '10px 0 0' } }, `数据目录:${state?.data_dir ?? '未配置'}`),
89
116
  h('p', { style: { opacity: 0.75, margin: '4px 0 0' } },
90
- `当前生效值来源:${SOURCE_LABEL[state?.auto_inject_source] ?? state?.auto_inject_source ?? '未知'}`),
117
+ `当前生效值来源:${SOURCE_LABEL[state?.modules_source] ?? state?.modules_source ?? '未知'}`),
91
118
  note ? h('p', { style: { opacity: 0.75, margin: '4px 0 0' } }, note) : null,
92
119
  );
93
120
  }
package/index.mjs CHANGED
@@ -12,33 +12,45 @@ import { createFeedHandler } from './src/tools/feed.mjs';
12
12
  import { createSelfHandler } from './src/tools/self.mjs';
13
13
  import { createRecordHandler } from './src/tools/record.mjs';
14
14
  import { runtimeFromExec } from './src/runtime/identity.mjs';
15
- import { createSummaryHandler, resolveAutoInject, writeUiConfig } from './src/runtime/summary.mjs';
15
+ import { createSummaryHandler } from './src/runtime/summary.mjs';
16
+ import { mergeModules, resolveModulesDetail, writeUiConfig } from './src/runtime/ui-config.mjs';
17
+ import { createRoundInjector } from './src/runtime/inject-round.mjs';
18
+ import { compileRound } from './src/context/compile.mjs';
16
19
 
17
20
  export const name = 'dsh-memsys';
18
21
  /** tools is a real host service; static inject is the verified registration path. */
19
22
  export const inject = ['tools'];
20
23
 
21
24
  /**
22
- * `auto_inject` has three ranks, highest first:
23
- * 1. `<data_dir>/ui-config.json` — written by the settings switch;
24
- * 2. the profile patch config (`config.auto_inject`);
25
- * 3. `false`.
25
+ * Module switches have three ranks, highest first:
26
+ * 1. `<data_dir>/ui-config.json` — written by the settings page;
27
+ * 2. the profile patch config (`auto_inject` / `modules`), which is itself a
28
+ * per-key overlay on the default map;
29
+ * 3. every module on.
26
30
  * The file wins so a toggle survives a restart without rewriting the profile,
27
- * and the in-memory value is applied immediately (no restart).
31
+ * and the in-memory map is applied immediately (no restart).
28
32
  */
29
33
  function createUiState(config = {}, logger = console) {
30
34
  const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
31
- let state = resolveAutoInject({ dataDir, profileValue: config.auto_inject, logger });
35
+ const detail = resolveModulesDetail({ dataDir, profileConfig: config, logger });
36
+ // One stable object: host tools capture it at registration, so a POST has to
37
+ // mutate it in place or the call-time gates would keep reading stale values.
38
+ const modules = { ...detail.modules };
39
+ let source = detail.source;
32
40
  return {
33
41
  dataDir,
34
- get autoInject() {
35
- return state.value;
42
+ get modules() {
43
+ return modules;
36
44
  },
37
45
  get source() {
38
- return state.source;
46
+ return source;
47
+ },
48
+ get autoInject() {
49
+ return modules.context !== false;
39
50
  },
40
- set autoInject(value) {
41
- state = { value: value === true, source: 'ui-config' };
51
+ apply(written) {
52
+ Object.assign(modules, written.modules);
53
+ source = 'ui-config';
42
54
  },
43
55
  sync: () => {},
44
56
  };
@@ -47,27 +59,32 @@ function createUiState(config = {}, logger = console) {
47
59
  function createHostHandlers(config = {}, ctx = null, ui = null) {
48
60
  const dataDir = config.data_dir ?? process.env.MEMSYS_DATA_DIR ?? null;
49
61
  const uiState = ui ?? {
50
- get autoInject() {
51
- return config.auto_inject === true;
62
+ get modules() {
63
+ return mergeModules(config, null);
52
64
  },
53
65
  get source() {
54
- return typeof config.auto_inject === 'boolean' ? 'profile' : 'default';
66
+ return typeof config.auto_inject === 'boolean' || config.modules ? 'profile' : 'default';
55
67
  },
56
- set autoInject(_value) {},
68
+ get autoInject() {
69
+ return mergeModules(config, null).context !== false;
70
+ },
71
+ apply() {},
57
72
  sync: () => {},
58
73
  };
59
74
  const uiSummary = createSummaryHandler({
60
75
  main: () => db().main,
61
76
  dataDir,
62
- getAutoInject: () => ({ value: uiState.autoInject, source: uiState.source }),
63
- onAutoInject: (next) => {
64
- if (!writeUiConfig(dataDir, next)) {
77
+ getConfig: () => ({ modules: uiState.modules, source: uiState.source }),
78
+ onConfig: (patch) => {
79
+ if (!dataDir) {
65
80
  const err = new Error('data_dir_missing');
66
81
  err.code = 'data_dir_missing';
67
82
  throw err;
68
83
  }
69
- uiState.autoInject = next;
84
+ const written = writeUiConfig(dataDir, patch);
85
+ uiState.apply(written);
70
86
  uiState.sync();
87
+ return { auto_inject: written.auto_inject, modules: written.modules };
71
88
  },
72
89
  });
73
90
  let handles = null;
@@ -148,7 +165,37 @@ function createHostHandlers(config = {}, ctx = null, ui = null) {
148
165
  export async function apply(ctx, config = {}) {
149
166
  const ui = createUiState(config);
150
167
  const handlers = createHostHandlers(config, ctx, ui);
151
- const registration = await registerHostTools({ ctx, handlers });
168
+ const registration = await registerHostTools({ ctx, handlers, modules: ui.modules });
169
+
170
+ // The round injector owns the session/turn subscription; the section only
171
+ // reads its current text. Verified empirically: a section's `text` function is
172
+ // re-evaluated on every `systemPrompt.assemble()` (once per model step), so no
173
+ // per-turn re-mount is needed — a toggle only has to change what `current()` says.
174
+ let injector = null;
175
+ const syncInjector = () => {
176
+ if (ui.modules.context === false || !ui.dataDir) {
177
+ injector?.dispose?.();
178
+ injector = null;
179
+ return;
180
+ }
181
+ if (!injector) {
182
+ injector = createRoundInjector({
183
+ ctx,
184
+ dataDir: ui.dataDir,
185
+ modules: ui.modules,
186
+ compileRound,
187
+ tokenizer: config.tokenizer ?? null,
188
+ H: config.H ?? null,
189
+ mode: config.mode ?? 'work',
190
+ });
191
+ try {
192
+ injector.refresh({});
193
+ } catch {
194
+ // a failed first compile must not stop the host half from booting
195
+ }
196
+ }
197
+ };
198
+ syncInjector();
152
199
  const toolProbe = process.env.MEMSYS_TOOL_PROBE ?? null;
153
200
  const writeToolProbe = (deferred) => {
154
201
  if (!toolProbe) return;
@@ -319,18 +366,22 @@ export async function apply(ctx, config = {}) {
319
366
  const syncSection = () => {
320
367
  disposeSection();
321
368
  disposeSection = () => {};
322
- if (!ui.autoInject && !diagnostic) return;
369
+ // The section is owned by the `context` module; the probe profile may force
370
+ // a diagnostic section even while injection is off.
371
+ if (ui.modules.context === false && !diagnostic) return;
323
372
  disposeSection = registerSection(ctx, {
324
373
  getText: () =>
325
- ui.autoInject
326
- ? '[memsys] 注入:tools-only(本批未取得宿主预算与回执,不作逐轮交付承诺)'
327
- : '[memsys] diagnostic section(auto_inject=false)',
374
+ ui.modules.context === false
375
+ ? '[memsys] diagnostic section(context=false)'
376
+ : (injector?.current().text ?? ''),
377
+ dynamicText: true,
328
378
  diagnosticMarker: process.env.MEMSYS_SECTION_PROBE ?? null,
329
379
  });
330
380
  };
331
381
  // A profile without systemPrompt (headless/embedded) must still boot the tools.
332
382
  ui.sync = () => {
333
383
  try {
384
+ syncInjector();
334
385
  syncSection();
335
386
  } catch {
336
387
  // keep the host half alive; the section is optional
@@ -339,6 +390,7 @@ export async function apply(ctx, config = {}) {
339
390
  ui.sync();
340
391
  return () => {
341
392
  disposeSection();
393
+ injector?.dispose?.();
342
394
  };
343
395
  }
344
396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memsys",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A memory system plugin for DeepSeek Harness: append-only facts, candidates and adjudication, dreams, and budget-aware context injection.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,3 +1,4 @@
1
+ import { currentRevision } from '../storage/open.mjs';
1
2
  import { payloadHash } from '../identity.mjs';
2
3
  import {
3
4
  COMPRESSION_STEPS,
@@ -188,6 +189,7 @@ function compileRoundInner({
188
189
  project = null,
189
190
  sessionId = null,
190
191
  dream = null,
192
+ modules = null,
191
193
  }) {
192
194
  if (!tokenizer || H === null) {
193
195
  return Object.freeze({
@@ -206,11 +208,11 @@ function compileRoundInner({
206
208
  feedback: (item) => feedback(item.id),
207
209
  });
208
210
  const depsDream = dream;
209
- const dueItems = orderPendingDue(selectPendingDue({ main, mode, now: clock.now() }));
211
+ const dueItems = modules?.due === false ? [] : orderPendingDue(selectPendingDue({ main, mode, now: clock.now() }));
210
212
  const dueLines = dueItems.map((item) => dueDirectoryLine(item, 80));
211
213
  const dueTypedIds = dueItems.map((item) => item.typed_id);
212
214
  const dreamSurface =
213
- depsDream && dreamSurfaceEnabled(main)
215
+ modules?.dream !== false && depsDream && dreamSurfaceEnabled(main)
214
216
  ? surfaceDreams({ main, dream: depsDream, clock, mode, sessionId: sessionId ?? 'unknown-session', enabled: true, limit: 1 })
215
217
  : { enabled: false, items: [] };
216
218
  const dreamItems = dreamSurface.items ?? [];
@@ -248,7 +250,6 @@ function compileRoundInner({
248
250
  const typedIds = project && mode === 'work' ? [`project:${project.projectId}`] : [];
249
251
  const fixedText = buildFixedText({ clock, mode, project: mode === 'work' ? project : null });
250
252
  const snapshotId = `snap:${payloadHash({
251
- roundId,
252
253
  mode,
253
254
  revision: selected.revision,
254
255
  policyRevision,
@@ -443,7 +444,7 @@ function compileRoundInner({
443
444
  roundId,
444
445
  mode,
445
446
  level,
446
- sourceRevision: selected.revision,
447
+ sourceRevision: currentRevision(main),
447
448
  policyRevision,
448
449
  sectionText: chosen.sectionText,
449
450
  sectionHash,
@@ -469,7 +470,7 @@ function compileRoundInner({
469
470
  mode: 'auto',
470
471
  snapshot_revision: snapshotId,
471
472
  roundId,
472
- source_revision: selected.revision,
473
+ source_revision: currentRevision(main),
473
474
  policy_revision: policyRevision,
474
475
  delivery_level: level,
475
476
  delivery: 'unknown',
@@ -50,7 +50,7 @@ export function recordDelivery({
50
50
  missing = expected.filter((id) => !receivedSet.has(id));
51
51
  extra = received.filter((id) => !expectedSet.has(id));
52
52
  hashMatched = payloadHash !== null && manifest?.hashes?.payload === payloadHash;
53
- if (missing.length === 0 && extra.length === 0 && hashMatched) {
53
+ if (actualMemories.length > 0 && missing.length === 0 && extra.length === 0 && hashMatched) {
54
54
  delivery = 'confirmed';
55
55
  } else if (received.length > 0) {
56
56
  delivery = 'incomplete';
@@ -277,8 +277,16 @@ export function getGroupPage({
277
277
  if (decoded.s !== snapshotRevision || decoded.g !== groupId) {
278
278
  return { status: 'error', code: 'invalid_cursor', reason: 'cursor_binding_mismatch', clock: clock.at().at };
279
279
  }
280
- if (main && Number(snapshot.source_revision) !== Number(currentRevision(main))) {
281
- return staleCursor(control, clock);
280
+ if (main) {
281
+ const manifest = snapshot.manifest_json ? JSON.parse(snapshot.manifest_json) : {};
282
+ const expectedCount = Array.isArray(manifest.eligible_ids) ? manifest.eligible_ids.length : null;
283
+ const currentCount = Number(main.prepare('SELECT COUNT(*) AS n FROM memories').get().n);
284
+ if (
285
+ Number(snapshot.source_revision) !== Number(currentRevision(main)) ||
286
+ (expectedCount !== null && currentCount > expectedCount)
287
+ ) {
288
+ return staleCursor(control, clock);
289
+ }
282
290
  }
283
291
  offset = decoded.o;
284
292
  }
package/src/read/get.mjs CHANGED
@@ -156,7 +156,7 @@ export function getMemories({
156
156
  return { status: 'error', code: 'validation_error', message: 'invalid_view', clock: reading };
157
157
  }
158
158
 
159
- let state = { ids, index: 0, offset: 0, versions: null };
159
+ let state = { ids, index: 0, offset: 0, versions: null, evIndex: null, evOffset: 0 };
160
160
  if (cursor !== null) {
161
161
  const decoded = decodeGetCursor(cursor);
162
162
  if (!decoded || decoded.ids.join(',') !== ids.join(',')) {
@@ -166,7 +166,7 @@ export function getMemories({
166
166
  if (JSON.stringify(decoded.versions ?? {}) !== JSON.stringify(currentVersions)) {
167
167
  return { status: 'error', code: 'stale_cursor', clock: reading };
168
168
  }
169
- state = { ids: decoded.ids, index: decoded.index, offset: decoded.offset, versions: decoded.versions };
169
+ state = { ids: decoded.ids, index: decoded.index, offset: decoded.offset, versions: decoded.versions, evIndex: decoded.evIndex ?? null, evOffset: decoded.evOffset ?? 0 };
170
170
  }
171
171
 
172
172
  const budget = clampChars(maxChars);
@@ -252,19 +252,70 @@ export function getMemories({
252
252
  history: view === 'history',
253
253
  });
254
254
  const text = formatted.text ?? '';
255
+ if (state.evIndex !== null) {
256
+ let left = budget - used;
257
+ const fragments = [];
258
+ let nextEv = null;
259
+ for (let e = state.evIndex; e < formatted.evidence.length; e += 1) {
260
+ const entry = formatted.evidence[e];
261
+ if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) continue;
262
+ const chars = Array.from(entry.observation_text);
263
+ const start = e === state.evIndex ? state.evOffset : 0;
264
+ const take = Math.max(0, Math.min(chars.length - start, left));
265
+ if (take > 0) fragments.push({ ...entry, observation_text: chars.slice(start, start + take).join(''), observation_text_truncated: start + take < chars.length });
266
+ left -= take;
267
+ if (start + take < chars.length) {
268
+ nextEv = { evIndex: e, evOffset: start + take };
269
+ break;
270
+ }
271
+ }
272
+ items.push({ ...formatted, evidence: fragments });
273
+ used = budget - left;
274
+ if (nextEv === null) {
275
+ index += 1;
276
+ offset = 0;
277
+ state.evIndex = null;
278
+ state.evOffset = 0;
279
+ continue;
280
+ }
281
+ const nextCursor = encodeGetCursor({
282
+ ids, index, offset: 0, evIndex: nextEv.evIndex, evOffset: nextEv.evOffset,
283
+ versions: state.versions ?? versionMapOf(main, ids),
284
+ });
285
+ return { status: 'ok', items, missing_ids: missing, snapshot_revision: currentRevision(main), next_cursor: nextCursor, truncated: true, clock: reading };
286
+ }
255
287
  if (offset === 0 && used + codePoints(text) <= budget) {
256
288
  if (includeEvidence && Array.isArray(formatted.evidence)) {
257
289
  let left = budget - used - codePoints(text);
258
- for (const entry of formatted.evidence) {
259
- if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) continue;
290
+ let nextEv = null;
291
+ const kept = [];
292
+ for (let e = 0; e < formatted.evidence.length; e += 1) {
293
+ const entry = formatted.evidence[e];
294
+ if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) {
295
+ kept.push(entry);
296
+ continue;
297
+ }
260
298
  const chars = Array.from(entry.observation_text);
261
299
  if (chars.length <= left) {
262
300
  left -= chars.length;
301
+ kept.push(entry);
263
302
  continue;
264
303
  }
265
- entry.observation_text = chars.slice(0, Math.max(0, left)).join('');
266
- entry.observation_text_truncated = true;
304
+ const take = Math.max(0, left);
305
+ kept.push({ ...entry, observation_text: chars.slice(0, take).join(''), observation_text_truncated: true });
306
+ nextEv = { evIndex: e, evOffset: take };
267
307
  left = 0;
308
+ break;
309
+ }
310
+ formatted.evidence = kept;
311
+ if (nextEv !== null) {
312
+ items.push(formatted);
313
+ used = budget;
314
+ const nextCursor = encodeGetCursor({
315
+ ids, index, offset: 0, evIndex: nextEv.evIndex, evOffset: nextEv.evOffset,
316
+ versions: state.versions ?? versionMapOf(main, ids),
317
+ });
318
+ return { status: 'ok', items, missing_ids: missing, snapshot_revision: currentRevision(main), next_cursor: nextCursor, truncated: true, clock: reading };
268
319
  }
269
320
  }
270
321
  items.push(formatted);
@@ -0,0 +1,138 @@
1
+ import { resolveConfig } from '../config.mjs';
2
+ import { openAll } from '../storage/open.mjs';
3
+ import { migrateAll } from '../storage/migrate.mjs';
4
+ import { systemClock } from '../clock.mjs';
5
+ import { withImmediate } from '../storage/open.mjs';
6
+
7
+ const TOOLS_ONLY_TEXT = '[memsys] 注入:tools-only(未取得宿主预算与回执,不作逐轮交付承诺)';
8
+
9
+ function sessionIdOf(turn) {
10
+ return turn?.session?.header?.id ?? turn?.sessionId ?? turn?.session?.id ?? null;
11
+ }
12
+
13
+ /**
14
+ * Runtime bridge between session turn events and the compile pipeline.
15
+ * Subscription is owned here (not the section), so a new turn refreshes text
16
+ * before the next prompt section read; no reply receipt means delivery=unknown.
17
+ */
18
+ export function createRoundInjector({
19
+ ctx = null,
20
+ dataDir,
21
+ modules = null,
22
+ compileRound,
23
+ tokenizer = null,
24
+ H = null,
25
+ mode = 'work',
26
+ clock = systemClock,
27
+ logger = console,
28
+ handles = null,
29
+ } = {}) {
30
+ const ownedHandles = !handles;
31
+ const stores = handles ?? openAll(resolveConfig({ dataDir }));
32
+ if (ownedHandles) migrateAll(stores, { clock });
33
+ let state = {
34
+ text: '',
35
+ delivery: 'unknown',
36
+ delivery_level: 'empty',
37
+ manifest: null,
38
+ sessionId: null,
39
+ roundId: null,
40
+ refreshed_at: null,
41
+ };
42
+ let counter = 0;
43
+ let disposed = false;
44
+
45
+ const disabled = modules?.context === false;
46
+
47
+ function recordInjections(roundId, manifest) {
48
+ const ids = manifest?.eligible_ids ?? [];
49
+ if (!ids.length) return;
50
+ const at = clock.at().at;
51
+ withImmediate(stores.main, () => {
52
+ for (const memoryId of ids) {
53
+ stores.main
54
+ .prepare('INSERT OR IGNORE INTO injections (round_id, memory_id, slots, topic_hits, manifest_hash, receipt_id, at) VALUES (?, ?, ?, 0, ?, NULL, ?)')
55
+ .run(roundId, memoryId, JSON.stringify(['context']), manifest?.manifest_hash ?? null, at);
56
+ }
57
+ });
58
+ }
59
+
60
+ function refresh(turn = {}) {
61
+ if (disposed) return state;
62
+ if (disabled) {
63
+ state = { text: '', delivery: 'unknown', delivery_level: 'disabled', manifest: null, sessionId: sessionIdOf(turn), roundId: null, refreshed_at: clock.at().at };
64
+ return state;
65
+ }
66
+ const sessionId = sessionIdOf(turn) ?? state.sessionId;
67
+ counter += 1;
68
+ const roundId = turn?.id ?? turn?.roundId ?? `turn-${counter}`;
69
+ let result;
70
+ try {
71
+ result = compileRound({
72
+ main: stores.main,
73
+ control: stores.control,
74
+ clock,
75
+ sessionId: sessionId ?? 'unknown-session',
76
+ roundId,
77
+ mode,
78
+ tokenizer,
79
+ H,
80
+ modules,
81
+ });
82
+ } catch (err) {
83
+ logger?.warn?.('[dsh-memsys] compile failed: ' + err.message);
84
+ result = null;
85
+ }
86
+ if (!result || result.delivery_level === 'unavailable') {
87
+ state = {
88
+ text: TOOLS_ONLY_TEXT,
89
+ delivery: 'unknown',
90
+ delivery_level: 'unavailable',
91
+ manifest: result?.manifest ?? null,
92
+ sessionId,
93
+ roundId,
94
+ refreshed_at: clock.at().at,
95
+ };
96
+ return state;
97
+ }
98
+ const text = result.section?.text ?? result.sectionText ?? '';
99
+ state = {
100
+ text,
101
+ delivery: 'unknown',
102
+ delivery_level: result.delivery_level,
103
+ manifest: result.manifest ?? null,
104
+ sessionId,
105
+ roundId,
106
+ refreshed_at: clock.at().at,
107
+ };
108
+ recordInjections(roundId, state.manifest);
109
+ return state;
110
+ }
111
+
112
+ function onSessionEvent(payload) {
113
+ const event = payload?.event ?? payload;
114
+ const type = event?.type ?? event?.name ?? event?.kind ?? null;
115
+ if (type !== 'turn/start') return;
116
+ refresh(payload?.turn ?? event?.turn ?? payload ?? {});
117
+ }
118
+
119
+ if (!disabled && typeof ctx?.on === 'function') {
120
+ ctx.on('session/event', onSessionEvent);
121
+ ctx.on('turn/start', (payload) => refresh(payload ?? {}));
122
+ }
123
+
124
+ return {
125
+ refresh,
126
+ current: () => state,
127
+ dispose() {
128
+ disposed = true;
129
+ try {
130
+ if (typeof ctx?.off === 'function') {
131
+ ctx.off('session/event', onSessionEvent);
132
+ }
133
+ } catch {
134
+ // disposal must not throw
135
+ }
136
+ },
137
+ };
138
+ }
@@ -7,8 +7,11 @@ export const SECTION_ORDER = 500;
7
7
  * The only verified injection call shape (probe-p0):
8
8
  * ctx.inject(['systemPrompt'], c => c.systemPrompt.section({name, order, text}))
9
9
  * Static `export const inject` would leave the plugin pending forever.
10
+ *
11
+ * `dynamicText: true` registers the resolver itself so the host re-reads it per
12
+ * assembly; the default keeps the historical one-shot string.
10
13
  */
11
- export function registerSection(ctx, { getText, diagnosticMarker = null } = {}) {
14
+ export function registerSection(ctx, { getText, diagnosticMarker = null, dynamicText = false } = {}) {
12
15
  if (!ctx || typeof ctx.inject !== 'function') {
13
16
  const err = new Error('invalid_ctx');
14
17
  err.code = 'invalid_ctx';
@@ -20,7 +23,12 @@ export function registerSection(ctx, { getText, diagnosticMarker = null } = {})
20
23
  err.code = 'system_prompt_unavailable';
21
24
  throw err;
22
25
  }
23
- const text = typeof getText === 'function' ? getText() : String(getText ?? '');
26
+ const resolve = () => (typeof getText === 'function' ? getText() : String(getText ?? ''));
27
+ // Dynamic text stays a function: the host re-evaluates it on every
28
+ // `systemPrompt.assemble()`, i.e. once per model step (verified on a probe
29
+ // profile: two assemblies produced probe-1 then probe-2). A static string
30
+ // would freeze the first round forever.
31
+ const text = dynamicText ? resolve : resolve();
24
32
  c.systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text });
25
33
  if (diagnosticMarker) {
26
34
  fs.writeFileSync(
@@ -29,7 +37,8 @@ export function registerSection(ctx, { getText, diagnosticMarker = null } = {})
29
37
  {
30
38
  name: SECTION_NAME,
31
39
  order: SECTION_ORDER,
32
- text_length: String(text).length,
40
+ text_length: String(resolve()).length,
41
+ dynamic: dynamicText === true,
33
42
  registered_at: new Date().toISOString(),
34
43
  },
35
44
  null,