@rennimars/dsh-weknora-memory 0.3.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — 常驻块按用户轮次投递
4
+ - **修(超时)**:`lib/weknora.js` 的 `request()` 改用自带 `AbortController` + `setTimeout` 计时,不再用 `AbortSignal.timeout()`——后者的定时器是 unref 的,事件循环里没有别的句柄时(Node 20 的 `node --test` 进程正是这样)它会随循环一起退出,挂起的请求永远不会结算,整份测试文件以 `cancelledByParent` 收场。现在 `finally` 里清计时器,超时原因是 `timeout`、连接错误仍是 `transport`。
5
+
6
+ - **修(画像闸门)**:`writers/profile-writer.mjs` 的复核此前只要「有来源 / 不重复 / 正文 ≤60 字」就把 `proposed` 转 `active`——一晚的静默被当成了决定。现在补上 `export const ATTEST_MIN = 3`:同一话题被反复看到(`propose()` 已有的 `attest` 合并计数)至少 3 次,或卡上 `approve: true`(`--approve` / `decide()` 写入的人工批准),才允许转正,否则记 `needs_approval_or_attest_N_of_3` 保持 `proposed`。
7
+ - **修(CI)**:`test/inject.test.mjs`、`test/layers.test.mjs` 曾 import 本机私有兄弟目录 `../../../_wk_dream/profile-writer.mjs`,本机绿、CI 上 `ERR_MODULE_NOT_FOUND`。改为仓库自带的 `../writers/profile-writer.mjs`。`test/writers.test.mjs` 的 W4 相应改成「人工批准的卡转正 + 只有 2 次证言的卡仍留 proposed」。66 个用例全绿。
8
+
9
+ - **修**:常驻/画像块(`pinned_constraints`,实测约 913 字)此前在**每个 step** 都重贴一次;DSH 的 `step()` 会把注入消息 `session.append('user/message', ...)` 写进会话,同一轮的后续步既读得到又不需要第二份。实测 `logs/inject.log` 2975 条里 2386 条(80%)是 `no_user_text` 的纯重播。
10
+ - **改**:`lib/injector.js` 新增 `pinnedForStep(sessionId, prompt)`,按本步的 prompt 文本给常驻块去重——工具续步没有 user 文本(`promptFromMessages` 返回 `""`),同一句 prompt 重复出现也算同轮。会话内第一块永远投递(`pinnedPrompt` 无该会话记录时不去重),所以「首步没有用户文本」不会让整场会话丢常驻层。
11
+ - **配置**:新增 `pinnedOncePerTurn`(默认 `true`,`lib/config.js`)。设为 `false` 恢复旧的每步投递。
12
+ - **日志**:被去重的步记 `pinned_skipped: true`、`pinned_chars: 0`,便于对前后数字。
13
+ - **测试**:新增 2 项(`test/inject.test.mjs`):同轮续步/同句重问不复投;`pinnedOncePerTurn: false` 保持旧行为。5 个测试文件全绿。
14
+
3
15
  ## 0.3.0 — 写入侧随包发布(可选组件)
4
16
 
5
17
  - **新增 `lib/weknora.js`**:读侧与写入侧共用的唯一 HTTP 客户端(base URL 拼接、`X-API-Key` 头、超时、`no_api_url|timeout|transport|http_<status>` 错误词表)。`lib/retrieve.js` 的两处内联 fetch 已改为调用它,59 个既有用例保持全绿。
package/README.md CHANGED
@@ -25,6 +25,8 @@
25
25
  | **画像层** `profile` | soul 规则 / user 画像 / agent 工作习惯 | soul 6 张·300 字 · user 3 张·200 字 · agent 6 张·400 字,单卡 ≤80 字 | 永不打分,按类配额裁剪 |
26
26
  | **检索层** `weknora_context` | 本轮检索命中的片段 | ≤2400 字符 · 最多 3 条 | 打分 + 衰减 + 分层共同决定 |
27
27
 
28
+ 常驻/画像块按**用户轮次**投递(`pinnedOncePerTurn`,默认开):同一轮的后续工具步只带检索片段,不再重贴同一份常驻内容——DSH 会把注入消息写进会话历史,第二份本来就是纯冗余。一个十步的回答,光这一项就省下九份约 900 字的块。
29
+
28
30
  三份预算是**相加而不是共享**的:检索块撑满 2400 字符也不会挤掉常驻准则(`lib/config.js:26,31`);画像层按类型分账,避免 user 卡把 agent 层吃空(`lib/config.js:104-110`)。
29
31
 
30
32
  上限是硬编码契约,不是建议值:`timeoutMs` 被钳到 **4000ms**、`maxChars` 被钳到 **2400**(`lib/config.js:19,26` 的 `TIMEOUT_CAP_MS` / `CHARS_CAP`),手改配置文件也撑不破。
@@ -57,7 +59,7 @@ agent/pre-step
57
59
  | `DSH-Session-Ingest` | 每小时 | 把**已结束且空闲 ≥10 分钟**的会话转录**原样**入库,跳过子代理会话;单篇 ≤200,000 字符,最多保留 150 篇;失败重试 3 次,入库后轮询索引 ≤600 秒 | 库① 会话记忆 |
58
60
  | `DSH-Session-Distill` | 每日 04:30 | 从最近会话里抽取结论 / 约束 / 偏好 / 教训,一次最多 3 个会话、3 篇文档,输入 ≤30,000 字符、每轮 ≤1500 字符、每会话 ≤8 条,语义去重阈值 0.66 | 库② 知识 |
59
61
  | `DSH-Memory-Dream` | 每日 03:30 | **夜间做梦**:五阶段 S0 收拢 → S1 分组 → S2 裁决 → S3 落账 → S4 报告。裁决同主题事实卡的取代 / 冲突 / 重复,**只改 header 里的状态字段,正文逐字保留**;单夜 ≤60 次 LLM 调用、≤20 次更新;支持 `--dry-run` / `--no-llm` 零成本演练 | 库② 知识(事实卡状态) |
60
- | `profile-writer` | 紧接做梦之后,且**只在做梦 rc=0 时** | `data/profile.json` 的**唯一写入者**:新卡一律 `proposed`,至少等一夜才复核;复核必须同时满足「有来源 / 不重复 / 不冲突 / 正文 ≤60 字」才转正,否则保持 `proposed` 并记日志;被插件标了 degrade 的 `active` 卡转 `disputed`,下一轮起不再进常驻块 | 常驻层画像 |
62
+ | `profile-writer` | 紧接做梦之后,且**只在做梦 rc=0 时** | `data/profile.json` 的**唯一写入者**:新卡一律 `proposed`,至少等一夜才复核;复核必须同时满足「有来源 / 不重复 / 不冲突 / 正文 ≤60 字」,且同一话题被反复看到至少 3 次(`attest ≥ 3`,`ATTEST_MIN`)或人显式 `--approve` 批准,才转正;否则保持 `proposed` 并记日志;被插件标了 degrade 的 `active` 卡转 `disputed`,下一轮起不再进常驻块 | 常驻层画像 |
61
63
  | `DSH-Memory-Weekly` | 每周 | 体检:跨库重复、失效文档、状态异常 | 三库 |
62
64
 
63
65
  三库的分工不是随手划的:
@@ -68,7 +70,7 @@ agent/pre-step
68
70
 
69
71
  **为什么写入侧必须独立于插件:** 写要 LLM、要数据库权限、要能被人工审计;而读侧每轮都跑,必须零依赖、可静默失败。两者混在一起,任何一次写入故障都会变成一次会话故障。
70
72
 
71
- **为什么画像文件只允许一个写入者:** 常驻内容每轮注入、跨会话传播——一旦被污染,后面没有任何一次检索能把它纠正回来。所以插件侧只读,任何新内容只能走 `proposed → 隔夜复核 → active` 这条闸门。
73
+ **为什么画像文件只允许一个写入者:** 常驻内容每轮注入、跨会话传播——一旦被污染,后面没有任何一次检索能把它纠正回来。所以插件侧只读,任何新内容只能走 `proposed → 隔夜复核(≥3 次证言或人工批准)→ active` 这条闸门——一晚的静默本身不是决定。
72
74
 
73
75
  ### 本包自带的写入侧参考实现(`writers/`,默认不启用)
74
76
 
@@ -201,3 +203,22 @@ npm test # node --test test/*.mjs,59 个用例
201
203
  ## License
202
204
 
203
205
  MIT © RenniMars
206
+
207
+ ## 发布流程
208
+
209
+ 版本号、CHANGELOG 与 git tag 三处同步,发布由 CI 完成,不在本机手工 `npm publish`:
210
+
211
+ 1. 改代码 → 在 `CHANGELOG.md` 顶部加一节(版本号 + 变更)→ `package.json` 的 `version` 改成同一个号。
212
+ 2. `npm test` 全绿。
213
+ 3. `git commit` → `git tag vX.Y.Z` → `git push origin main --follow-tags`。
214
+ 4. `.github/workflows/release.yml` 校验 tag 与 `package.json` 版本一致后,跑测试、`npm publish --provenance`(用的是仓库 secret `NPM_TOKEN`),再发 GitHub Release。
215
+ 5. `.github/workflows/ci.yml` 在每次 push / PR 上跑 Node 20 与 22 两个版本的测试。
216
+
217
+ 回滚:npm 侧 `npm deprecate @rennimars/dsh-weknora-memory@X.Y.Z "原因"` 并发布修正版;GitHub 侧 `git revert` 后重新打 tag,不移动已有 tag。
218
+
219
+ ## 路线图 / Roadmap
220
+
221
+ 本项目的演进计划分阶段推进:先给"检索准不准"一把可量化的尺子,再让事实卡的冲突能被裁决、遗忘能被解释、主题能被归并,最后才谈写入门控。
222
+ 完整计划(13 节,含每阶段的目标、改动点、验收与回滚):[docs/ROADMAP.md](docs/ROADMAP.md)
223
+ 当前版本 0.3.1;计划可分批实现、每批独立验收,欢迎在 Issue 区按阶段提方案。
224
+ 想改这份计划,请先开 Issue 讨论,达成一致后再改文档,不要直接改 ROADMAP.md。
package/lib/config.js CHANGED
@@ -110,6 +110,13 @@ export const DEFAULT_CONFIG = Object.freeze({
110
110
  * a ceiling, not a target -- a file with fewer cards renders a shorter block.
111
111
  */
112
112
  pinnedMaxChars: 1500,
113
+ /**
114
+ * The pinned block is configuration, not retrieval: it renders identically on every step
115
+ * of one user turn. DSH persists each injected message into the session, so one copy per
116
+ * turn is still readable while later steps would only repeat it. true = once per turn,
117
+ * false = the old per-step behaviour.
118
+ */
119
+ pinnedOncePerTurn: true,
113
120
  /**
114
121
  * Standing profile facts (data/profile.json). The plugin only ever reads this file:
115
122
  * every write belongs to the nightly task (_wk_dream/profile-writer.mjs). That is the
@@ -507,6 +514,7 @@ export function loadConfig(overrides = {}, options = {}) {
507
514
  panelEnabled: merged.panelEnabled !== false,
508
515
  pinned: normalizePinned(merged.pinned),
509
516
  pinnedMaxChars: clampInt(merged.pinnedMaxChars, DEFAULT_CONFIG.pinnedMaxChars, 120, PINNED_MAX_CHARS_CAP),
517
+ pinnedOncePerTurn: merged.pinnedOncePerTurn !== false,
510
518
  profile,
511
519
  decay: normalizeDecay(merged.decay, packageRoot),
512
520
  tier: normalizeTier(merged.tier, env),
package/lib/injector.js CHANGED
@@ -341,6 +341,14 @@ export function createWeKnoraInjector(options = {}) {
341
341
  const retrieve = options.retrieve ?? searchWeKnora;
342
342
  const injected = new Map();
343
343
  const lastPrompt = new Map();
344
+ /**
345
+ * The prompt each session's pinned block was last injected for. The pinned layer is
346
+ * configuration: it cannot change between two steps of one user turn, and DSH appends
347
+ * every injected message to the session, so the step-1 copy stays readable from history.
348
+ * Re-injecting it per step therefore buys no information and costs the whole block each
349
+ * time -- measured at 80% of all injection records being pinned-only repeats.
350
+ */
351
+ const pinnedPrompt = new Map();
344
352
 
345
353
  const seenFor = (sessionId) => {
346
354
  let set = injected.get(sessionId);
@@ -396,6 +404,35 @@ export function createWeKnoraInjector(options = {}) {
396
404
  return { ...pinned, fields };
397
405
  };
398
406
 
407
+ /**
408
+ * The pinned block for this step: built once per user turn, empty on a repeat.
409
+ *
410
+ * "Once per user turn" is keyed on the step's own prompt text, because that is the only
411
+ * turn identity this hook sees: a tool-continuation step carries no user-role text at
412
+ * all. The first step of a session always gets the block (pinnedPrompt has no entry for
413
+ * it yet), so a step that arrives without user text can never cost a session its
414
+ * standing rules; after that, an empty prompt or an identical one is a repeat.
415
+ * pinnedOncePerTurn: false restores the old behaviour of injecting on every step.
416
+ */
417
+ const pinnedForStep = (sessionId, prompt) => {
418
+ if (config.pinnedOncePerTurn !== false && pinnedPrompt.has(sessionId)
419
+ && (prompt === "" || pinnedPrompt.get(sessionId) === prompt)) {
420
+ return {
421
+ text: "",
422
+ skipped: true,
423
+ fields: { pinned_chars: 0, pinned_count: 0, pinned_truncated: false, pinned_skipped: true },
424
+ };
425
+ }
426
+ const pinned = pinnedFor();
427
+ if (config.pinnedOncePerTurn !== false) {
428
+ if (pinnedPrompt.size >= MAX_SESSIONS && !pinnedPrompt.has(sessionId)) {
429
+ pinnedPrompt.delete(pinnedPrompt.keys().next().value);
430
+ }
431
+ pinnedPrompt.set(sessionId, prompt);
432
+ }
433
+ return pinned;
434
+ };
435
+
399
436
  const enhance = async (input, decision) => {
400
437
  if (!decision || decision.kind !== "enter") return decision;
401
438
  if (config.enabled === false) return decision;
@@ -408,7 +445,9 @@ export function createWeKnoraInjector(options = {}) {
408
445
  // A card promoted by the nightly task has to appear without a restart: the profile
409
446
  // file is re-read only when its mtime moved, so this is one stat() on the common path.
410
447
  refreshProfileCards(config?.profile);
411
- const pinned = pinnedFor();
448
+ // The prompt is read before the pinned block: which block this step gets depends on it.
449
+ const prompt = promptFromMessages(decision.messages);
450
+ const pinned = pinnedForStep(sessionId, prompt);
412
451
  /**
413
452
  * Close the step: compose pinned first, retrieval second, and log exactly once.
414
453
  * record carries this path's outcome and accounting; when the composed body is empty
@@ -470,7 +509,6 @@ export function createWeKnoraInjector(options = {}) {
470
509
  return { ...decision, messages: [...messages, injectionMessage(body)] };
471
510
  };
472
511
 
473
- const prompt = promptFromMessages(decision.messages);
474
512
  if (!prompt) {
475
513
  return finish({ outcome: "skip", reason: "no_user_text", session: sessionId }, "", []);
476
514
  }
@@ -557,6 +595,7 @@ export function createWeKnoraInjector(options = {}) {
557
595
  forget: (sessionId) => {
558
596
  injected.delete(sessionId);
559
597
  lastPrompt.delete(sessionId);
598
+ pinnedPrompt.delete(sessionId);
560
599
  },
561
600
  };
562
601
  }
package/lib/weknora.js CHANGED
@@ -59,8 +59,16 @@ export async function request(config, method, path, options = {}) {
59
59
  const base = baseUrlOf(config);
60
60
  if (!base) return { ok: false, reason: "no_api_url" };
61
61
 
62
+ // 自带计时器而不是 AbortSignal.timeout():后者的定时器是 unref 的,事件循环里没有别的
63
+ // 句柄时它会随循环一起退出,pending 的 promise 就再也不会结算(Node 20 的测试进程里正好如此)。
62
64
  const signals = [];
63
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) signals.push(AbortSignal.timeout(timeoutMs));
65
+ let deadline = null;
66
+ let deadlineTimer = null;
67
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
68
+ deadline = new AbortController();
69
+ deadlineTimer = setTimeout(() => deadline.abort(), timeoutMs);
70
+ signals.push(deadline.signal);
71
+ }
64
72
  if (signal) signals.push(signal);
65
73
 
66
74
  const init = { method, headers: headersFor(config, { json: body !== undefined }) };
@@ -75,6 +83,9 @@ export async function request(config, method, path, options = {}) {
75
83
  return { ok: true, status: response.status, body: await response.json() };
76
84
  } catch (error) {
77
85
  const name = error?.name;
78
- return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "timeout" : "transport" };
86
+ const reason = (Number.isFinite(timeoutMs) && timeoutMs > 0 && deadline?.signal.aborted) || name === "TimeoutError" || name === "AbortError" ? "timeout" : "transport";
87
+ return { ok: false, reason };
88
+ } finally {
89
+ if (deadlineTimer) clearTimeout(deadlineTimer);
79
90
  }
80
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rennimars/dsh-weknora-memory",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Layered context engine for DeepSeek Harness: per-kb WeKnora retrieval fused by RRF, recency decay and hot/warm/cold tiering, standing profile cards and fact-conflict surfacing — budgeted, auditable, editable.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -53,6 +53,9 @@ export const REVIEW_TEXT_MAX = 60;
53
53
  */
54
54
  export const DEGRADE_AFTER = 50;
55
55
 
56
+ /** 同一话题的重复出现算一次'证言';攒够这么多次、或人工批准,才允许 proposed -> active。 */
57
+ export const ATTEST_MIN = 3;
58
+
56
59
  const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
57
60
  const charLen = (value) => Array.from(clean(value)).length;
58
61
  const KINDS = new Set(['user', 'agent']);
@@ -228,6 +231,13 @@ export function review(profile, { today = dayOf(), stats = {}, degrade = true }
228
231
  decisions.push({ id: card.id, action: 'kept_proposed', reason: 'conflict:' + conflict });
229
232
  continue;
230
233
  }
234
+ // 一晚的静默不是决定:只有同一话题被反复看到(attest 攒够),或人显式批准,才转正。
235
+ const approved = card.approve === true;
236
+ const attest = Number(card.attest) || 0;
237
+ if (!approved && attest < ATTEST_MIN) {
238
+ decisions.push({ id: card.id, action: 'kept_proposed', reason: 'needs_approval_or_attest_' + attest + '_of_' + ATTEST_MIN });
239
+ continue;
240
+ }
231
241
  card.status = 'active';
232
242
  decisions.push({ id: card.id, action: 'activated', reason: 'source+unique+under_' + REVIEW_TEXT_MAX });
233
243
  continue;
@@ -10,14 +10,14 @@
10
10
  .\register-tasks.ps1 -Prefix WK # rename the task group (default DSH-Memory)
11
11
 
12
12
  Tasks are registered for the current user only: no admin rights, no system-wide
13
- service. Times can be changed in -Times like @{ Ingest='hourly'; Distill='04:30'; Dream='03:30' }.
13
+ service. Times can be changed in -Times like @{ Ingest='hourly'; Distill='00:00'; Dream='00:30' }.
14
14
  #>
15
15
  [CmdletBinding()]
16
16
  param(
17
17
  [switch]$Unregister,
18
18
  [string]$Prefix = 'DSH-Memory',
19
19
  [string]$Config = '',
20
- [hashtable]$Times = @{ Ingest = 'hourly'; Distill = '04:30'; Dream = '03:30' }
20
+ [hashtable]$Times = @{ Ingest = 'hourly'; Distill = '00:00'; Dream = '00:30' }
21
21
  )
22
22
 
23
23
  $ErrorActionPreference = 'Stop'