@rennimars/dsh-weknora-memory 0.2.1 → 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,33 @@
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
+
15
+ ## 0.3.0 — 写入侧随包发布(可选组件)
16
+
17
+ - **新增 `lib/weknora.js`**:读侧与写入侧共用的唯一 HTTP 客户端(base URL 拼接、`X-API-Key` 头、超时、`no_api_url|timeout|transport|http_<status>` 错误词表)。`lib/retrieve.js` 的两处内联 fetch 已改为调用它,59 个既有用例保持全绿。
18
+ - **新增 `writers/`(默认不启用)**:`ingest.mjs` 会话原文入库①、`distill.mjs` 结论蒸馏入库②、`dream.mjs` 事实卡裁决 S0–S4、`profile-writer.mjs` 画像唯一写入者。四者都支持 `--config` / `--dry-run` / `--verbose`,失败返回可读错误与明确退出码,`--dry-run` 不写库、不写文件、不写 state。
19
+ - **新增 `writers/register-tasks.ps1`** 与 **`writers/run-all.cmd`**:幂等注册/撤销计划任务(当前用户级,不需要管理员),以及手动跑一轮(可选单步)。
20
+ - **凭证纪律**:`writer.config.json` 的 `apiKey` 字段被忽略,只从 `WEKNORA_API_KEY` 环境变量读取;发布包内不含任何密钥、知识库 id 或本机绝对路径。
21
+ - **新增测试 `test/writers.test.mjs`(5 项)**:import 零副作用、`--help` 退出 0、桩地址 dry-run 不落盘且错误可读、`proposed→active`/`degrade→disputed` 只改状态字段、发布树无凭证/无机器路径。总计 64 个用例。
22
+ - 安装后**不会**自动注册任何计划任务,读侧行为与 0.2.2 完全一致。
23
+
24
+ ## 0.2.2 — 补上写入侧(文档)
25
+
26
+ - 新增「写入侧:这些记忆是谁写进去的」一节:三条管道(每小时会话原文入库① / 每日 04:30 结论蒸馏入库② / 每日 03:30 夜间做梦裁决事实卡)与画像写入器的闸门(`proposed` → 隔夜复核 → `active`,被标记 degrade 的卡转 `disputed`),含触发时间、配额与失败策略。
27
+ - 明确边界:这些定时任务属于**宿主侧**,不随本包分发;装上插件不会自动获得写入能力。写清这点是为了不让"记忆插件"的想象越界成实际行为。
28
+ - 补三库分工(原文 / 结论 / 原始材料)与"为什么写入必须独立于读侧"的取舍说明。
29
+ - 代码与默认配置未改动。
30
+
3
31
  ## 0.2.1 — 介绍重写(仅文档)
4
32
 
5
33
  - README 从“功能清单”改写为**机制说明**:三层上下文(常驻 / 画像 / 检索)各自的独立预算、一轮之内的完整检索管线(分库并发 → RRF → 来源权重 → 时效衰减 → 冷热分层 → 阈值 → 会话去重 → 邻块扩展 → 事实卡冲突 → 渲染),以及每处设计取舍的理由。
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`),手改配置文件也撑不破。
@@ -48,7 +50,58 @@ agent/pre-step
48
50
 
49
51
  最后一步的细节很重要:注入块是**一条独立的 user-role 消息**,不是对你的提问的改写,模型能分辨"这是检索来的历史材料"而不是"用户刚说的新任务"。
50
52
 
51
- ## 四、几个刻意的设计取舍
53
+ ## 四、写入侧:这些记忆是谁写进去的
54
+
55
+ 本包只做**读**(每轮注入)和**人工编辑**(面板)。把原文、结论、画像写进 WeKnora 与 `data/profile.json` 的,是宿主侧的定时任务——**装上本插件不会自动获得这些任务**,它们是同一套体系的另一半,这里把契约写清楚,方便你自己实现或用别的工具替代。
56
+
57
+ | 任务 | 触发 | 做什么 | 写到哪里 |
58
+ |---|---|---|---|
59
+ | `DSH-Session-Ingest` | 每小时 | 把**已结束且空闲 ≥10 分钟**的会话转录**原样**入库,跳过子代理会话;单篇 ≤200,000 字符,最多保留 150 篇;失败重试 3 次,入库后轮询索引 ≤600 秒 | 库① 会话记忆 |
60
+ | `DSH-Session-Distill` | 每日 04:30 | 从最近会话里抽取结论 / 约束 / 偏好 / 教训,一次最多 3 个会话、3 篇文档,输入 ≤30,000 字符、每轮 ≤1500 字符、每会话 ≤8 条,语义去重阈值 0.66 | 库② 知识 |
61
+ | `DSH-Memory-Dream` | 每日 03:30 | **夜间做梦**:五阶段 S0 收拢 → S1 分组 → S2 裁决 → S3 落账 → S4 报告。裁决同主题事实卡的取代 / 冲突 / 重复,**只改 header 里的状态字段,正文逐字保留**;单夜 ≤60 次 LLM 调用、≤20 次更新;支持 `--dry-run` / `--no-llm` 零成本演练 | 库② 知识(事实卡状态) |
62
+ | `profile-writer` | 紧接做梦之后,且**只在做梦 rc=0 时** | `data/profile.json` 的**唯一写入者**:新卡一律 `proposed`,至少等一夜才复核;复核必须同时满足「有来源 / 不重复 / 不冲突 / 正文 ≤60 字」,且同一话题被反复看到至少 3 次(`attest ≥ 3`,`ATTEST_MIN`)或人显式 `--approve` 批准,才转正;否则保持 `proposed` 并记日志;被插件标了 degrade 的 `active` 卡转 `disputed`,下一轮起不再进常驻块 | 常驻层画像 |
63
+ | `DSH-Memory-Weekly` | 每周 | 体检:跨库重复、失效文档、状态异常 | 三库 |
64
+
65
+ 三库的分工不是随手划的:
66
+
67
+ - **库① 会话记忆**存原文,回答"当时到底怎么说的";
68
+ - **库② 知识**存结论,一条结论一张事实卡,回答"这件事定了没有、为什么";
69
+ - **库③ 资料**存你手工入库的原始文件,回答"这份规范里怎么写的"。
70
+
71
+ **为什么写入侧必须独立于插件:** 写要 LLM、要数据库权限、要能被人工审计;而读侧每轮都跑,必须零依赖、可静默失败。两者混在一起,任何一次写入故障都会变成一次会话故障。
72
+
73
+ **为什么画像文件只允许一个写入者:** 常驻内容每轮注入、跨会话传播——一旦被污染,后面没有任何一次检索能把它纠正回来。所以插件侧只读,任何新内容只能走 `proposed → 隔夜复核(≥3 次证言或人工批准)→ active` 这条闸门——一晚的静默本身不是决定。
74
+
75
+ ### 本包自带的写入侧参考实现(`writers/`,默认不启用)
76
+
77
+ 上面那张表是**它应该长什么样**;本包把同一套约定做成了可复制的实现,放在 `writers/`,**随包分发但默认不运行**——装上插件不会创建任何计划任务、不会写任何文档。
78
+
79
+ | 文件 | 作用 |
80
+ |---|---|
81
+ | `writers/ingest.mjs` | 会话转录 → 库①(幂等:同名文档走 PUT 复用 id) |
82
+ | `writers/distill.mjs` | 会话 → 结论 → 库②(含语义去重,LLM 走 WeKnora 会话网关) |
83
+ | `writers/dream.mjs` | 事实卡裁决 S0–S4,只改 header 状态;`--self-test` 可离线跑 |
84
+ | `writers/profile-writer.mjs` | `data/profile.json` 的唯一写入者;不带 `--write` 就只打印 diff |
85
+ | `writers/register-tasks.ps1` | 幂等注册/更新计划任务(`-Unregister` 撤销,`-Prefix`、`-Times` 可改) |
86
+ | `writers/run-all.cmd` | 手动跑一轮:`run-all.cmd [--dry-run] [ingest|distill|dream|profile]` |
87
+
88
+ 三步启用:
89
+
90
+ ```powershell
91
+ cd node_modules/@rennimars/dsh-weknora-memory
92
+ copy writers\config.example.json writers\writer.config.json # 填 apiUrl / sessionsRoot / kbs
93
+ $env:WEKNORA_API_KEY = "<你的 key>" # 只从环境变量读,不落文件
94
+ powershell -ExecutionPolicy Bypass -File writers\register-tasks.ps1
95
+ ```
96
+
97
+ 四条纪律:
98
+
99
+ - **凭证只在环境变量**:`writer.config.json` 里的 `apiKey` 字段会被忽略;缺 `WEKNORA_API_KEY` 时只提示,不带该请求头。
100
+ - **演练零成本**:四个脚本都支持 `--dry-run`(不写库、不写文件、不写 state);`dream.mjs --dry-run --no-llm` 连一次 LLM 都不发。
101
+ - **共用一份 HTTP**:读侧 `lib/retrieve.js` 与写入侧全部走 `lib/weknora.js`,`X-API-Key` 头与错误词表只有一处实现。
102
+ - **失败可见**:退出码 0/1/2/3 有明确语义,失败是一条可读错误而不是异常栈——这与读侧「失败即静默」刻意相反,因为定时任务没有人在看着。
103
+
104
+ ## 五、几个刻意的设计取舍
52
105
 
53
106
  **不改用户原话。** 注入永远走追加,避免任何"提示词污染"式的隐蔽改写。
54
107
 
@@ -62,7 +115,7 @@ agent/pre-step
62
115
 
63
116
  **自动写入与人工编辑分权。** `data/profile.json` 的自动更新属于夜间任务,插件本身只读;面板里的编辑是用户显式动作。这条边界防的是"每轮注入、每轮重算"的自我强化——一个被污染的事实否则会跨会话传播。
64
117
 
65
- ## 五、可观测性
118
+ ## 六、可观测性
66
119
 
67
120
  侧边栏「记忆」页签,三个视图:
68
121
 
@@ -74,7 +127,7 @@ agent/pre-step
74
127
 
75
128
  日志只记元数据:会话 id、耗时、命中数、字符数、结果。**不写提问原文,也不写片段正文**。
76
129
 
77
- ## 六、依赖
130
+ ## 七、依赖
78
131
 
79
132
  | 项 | 要求 |
80
133
  |---|---|
@@ -82,7 +135,7 @@ agent/pre-step
82
135
  | Node | `>= 20.11` |
83
136
  | WeKnora | 一个可访问实例(默认 `http://127.0.0.1:8080/api/v1`),以及至少一个知识库 |
84
137
 
85
- ## 七、安装
138
+ ## 八、安装
86
139
 
87
140
  **从插件市场**:插件页 → 插件市场 → 搜索 `weknora` → 安装。
88
141
 
@@ -96,7 +149,7 @@ git clone https://github.com/RenniMars/dsh-weknora-memory node_modules/@rennimar
96
149
 
97
150
  然后在同目录 `package.json` 的 `dsh.profile.bundles` 里加一行 `"@rennimars/dsh-weknora-memory"`,重启客户端。
98
151
 
99
- ## 八、配置
152
+ ## 九、配置
100
153
 
101
154
  复制模板,填三样东西:
102
155
 
@@ -122,13 +175,13 @@ cp wk-inject.config.example.json wk-inject.config.json
122
175
 
123
176
  其余参数(超时、字符上限、命中数、阈值、RRF k、邻块扩展、衰减半衰期与下限、冷热分层配额、画像卡配额、事实卡权重、日志路径)见 `lib/config.js` 的 `DEFAULT_CONFIG`,每一项都带注释说明为什么取这个值;一般不用动。
124
177
 
125
- ## 九、隐私与边界
178
+ ## 十、隐私与边界
126
179
 
127
180
  - `data/profile.json`、`data/profile-stats.json`、`data/tier-state.json`、`logs/inject.log` 全部留在本机,不进仓库、不进包(`.gitignore` / `.npmignore` 都排除了)。
128
181
  - 本插件不向任何第三方上报数据;所有请求只发往你自己配置的 `apiUrl`。
129
182
  - 子代理会话默认不检索(`skipSubagents: true`):子代理拿到的是被裁剪过的任务,用它的文本去检索只会污染主会话的记忆。
130
183
 
131
- ## 十、开发
184
+ ## 十一、开发
132
185
 
133
186
  ```bash
134
187
  npm test # node --test test/*.mjs,59 个用例
@@ -144,7 +197,28 @@ npm test # node --test test/*.mjs,59 个用例
144
197
  | `lib/decay.js` | 时效衰减与命中回写(原子 JSON 状态) |
145
198
  | `lib/config.js` | 配置加载、钳制、日志 |
146
199
  | `lib/panel.js` / `lib/client.js` | 面板同源路由 / GUI 侧渲染 |
200
+ | `lib/weknora.js` | 唯一 HTTP 客户端:读侧与写入侧共用 |
201
+ | `writers/*.mjs` | 可选写入侧:ingest / distill / dream / profile-writer |
147
202
 
148
203
  ## License
149
204
 
150
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/retrieve.js CHANGED
@@ -25,6 +25,7 @@
25
25
  * that call. Any failure there is silent: the hit is injected exactly as it arrived.
26
26
  */
27
27
  import { TIMEOUT_CAP_MS } from "./config.js";
28
+ import { request } from "./weknora.js";
28
29
  import { applyDecay, readState } from "./decay.js";
29
30
  import { cleanText } from "./format.js";
30
31
 
@@ -158,32 +159,17 @@ function keyOf(hit, group) {
158
159
 
159
160
  /** One scoped search call; kbId "" means "no scope" (search everything). */
160
161
  async function searchOnce(query, config, kbId, deps) {
161
- const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
162
- const timeoutMs = timeoutOf(config);
163
- const base = String(config.apiUrl ?? "").replace(/\/+$/, "");
164
- if (!base) return { ok: false, reason: "no_api_url" };
165
- const url = base + "/knowledge-search";
166
- const signals = [AbortSignal.timeout(timeoutMs)];
167
- if (deps.signal) signals.push(deps.signal);
168
- const headers = { "Content-Type": "application/json", Accept: "application/json" };
169
- if (config.apiKey) headers["X-API-Key"] = config.apiKey;
170
162
  const body = { query, top_k: topKOf(config) };
171
163
  if (kbId) body.knowledge_base_ids = [kbId];
172
- try {
173
- const response = await fetchImpl(url, {
174
- method: "POST",
175
- headers,
176
- body: JSON.stringify(body),
177
- signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
178
- });
179
- if (!response.ok) return { ok: false, reason: "http_" + response.status };
180
- const payload = await response.json();
181
- const hits = Array.isArray(payload?.data) ? payload.data : [];
182
- return { ok: true, hits };
183
- } catch (error) {
184
- const name = error?.name;
185
- return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "timeout" : "transport" };
186
- }
164
+ const result = await request(config, "POST", "/knowledge-search", {
165
+ body,
166
+ timeoutMs: timeoutOf(config),
167
+ signal: deps.signal,
168
+ fetchImpl: deps.fetchImpl,
169
+ });
170
+ if (!result.ok) return { ok: false, reason: result.reason };
171
+ const hits = Array.isArray(result.body?.data) ? result.body.data : [];
172
+ return { ok: true, hits };
187
173
  }
188
174
 
189
175
  /**
@@ -289,30 +275,19 @@ function sliceHead(text, limit) {
289
275
  * binarySearchPage() over the page index ranges instead of giving up.
290
276
  */
291
277
  async function fetchChunkPage(knowledgeId, page, config, deps, timeoutMs) {
292
- const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
293
- const base = String(config?.apiUrl ?? "").replace(/\/+$/, "");
294
- if (!base) return null;
295
- const url = base + "/chunks/" + encodeURIComponent(knowledgeId) + "?page=" + page + "&page_size=" + CHUNK_PAGE_SIZE;
296
- const headers = { Accept: "application/json" };
297
- if (config.apiKey) headers["X-API-Key"] = config.apiKey;
298
- const signals = [AbortSignal.timeout(Math.max(MIN_REQUEST_MS, timeoutMs))];
299
- if (deps.signal) signals.push(deps.signal);
300
- try {
301
- const response = await fetchImpl(url, {
302
- method: "GET",
303
- headers,
304
- signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
305
- });
306
- if (!response.ok) return null;
307
- const payload = await response.json();
308
- return {
309
- list: Array.isArray(payload?.data) ? payload.data : [],
310
- total: Number.isFinite(payload?.total) ? Number(payload.total) : null,
311
- pageSize: Number.isFinite(payload?.page_size) && payload.page_size > 0 ? Number(payload.page_size) : null,
312
- };
313
- } catch {
314
- return null;
315
- }
278
+ const result = await request(
279
+ config,
280
+ "GET",
281
+ "/chunks/" + encodeURIComponent(knowledgeId) + "?page=" + page + "&page_size=" + CHUNK_PAGE_SIZE,
282
+ { timeoutMs: Math.max(MIN_REQUEST_MS, timeoutMs), signal: deps.signal, fetchImpl: deps.fetchImpl }
283
+ );
284
+ if (!result.ok) return null;
285
+ const payload = result.body;
286
+ return {
287
+ list: Array.isArray(payload?.data) ? payload.data : [],
288
+ total: Number.isFinite(payload?.total) ? Number(payload.total) : null,
289
+ pageSize: Number.isFinite(payload?.page_size) && payload.page_size > 0 ? Number(payload.page_size) : null,
290
+ };
316
291
  }
317
292
 
318
293
  /** One fetched page, indexed by chunk id and by chunk_index, with its index range. */
package/lib/weknora.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The one WeKnora HTTP client.
3
+ *
4
+ * Both sides of the memory system go through here: the injector (lib/retrieve.js,
5
+ * read path, every step) and the optional writers (writers/*, write path, scheduled).
6
+ * They were two hand-rolled fetch blocks before this module existed, which is exactly
7
+ * how a header or an error string drifts apart between the two.
8
+ *
9
+ * Nothing in here throws for transport, timeout or HTTP status: a call resolves to
10
+ * { ok, status, body, reason } and the caller decides whether that is fatal. The read
11
+ * path is required to degrade silently, the writers are required to log and exit
12
+ * non-zero, so the decision has to stay with them.
13
+ */
14
+
15
+ /** Request ceiling applied when a caller names no timeout of its own. */
16
+ export const DEFAULT_TIMEOUT_MS = 4000;
17
+
18
+ /** The configured base URL without trailing slashes, or "" when unset. */
19
+ export function baseUrlOf(config) {
20
+ return String(config?.apiUrl ?? "").replace(/\/+$/, "");
21
+ }
22
+
23
+ /** Join a base URL and a path, tolerating a missing or extra leading slash. */
24
+ export function joinUrl(base, path) {
25
+ const root = String(base ?? "").replace(/\/+$/, "");
26
+ const tail = String(path ?? "");
27
+ return root + (tail.startsWith("/") ? tail : "/" + tail);
28
+ }
29
+
30
+ /**
31
+ * Request headers: X-API-Key only when a key is configured, and the JSON content
32
+ * type only when a body is actually sent (a GET with Content-Type is noise).
33
+ */
34
+ export function headersFor(config, { json = false } = {}) {
35
+ const headers = json
36
+ ? { "Content-Type": "application/json", Accept: "application/json" }
37
+ : { Accept: "application/json" };
38
+ const key = config?.apiKey;
39
+ if (typeof key === "string" && key) headers["X-API-Key"] = key;
40
+ return headers;
41
+ }
42
+
43
+ /**
44
+ * One request. `reason` is one of no_api_url / timeout / transport / http_<status>,
45
+ * which is the vocabulary lib/retrieve.js already reports in its stats.
46
+ *
47
+ * @param {object} config clamped config (apiUrl / apiKey)
48
+ * @param {string} method HTTP method
49
+ * @param {string} path path below the base URL, e.g. "/knowledge-search"
50
+ * @param {object} [options]
51
+ * @param {object} [options.body] JSON body; omit for a body-less request
52
+ * @param {number} [options.timeoutMs] per-request ceiling; omitted means no deadline
53
+ * @param {AbortSignal} [options.signal] caller's own deadline/cancellation
54
+ * @param {Function} [options.fetchImpl] injected fetch (tests)
55
+ * @param {"json"|"none"} [options.expect] parse the body as JSON (default) or ignore it
56
+ */
57
+ export async function request(config, method, path, options = {}) {
58
+ const { body, timeoutMs, signal, fetchImpl = globalThis.fetch, expect = "json" } = options;
59
+ const base = baseUrlOf(config);
60
+ if (!base) return { ok: false, reason: "no_api_url" };
61
+
62
+ // 自带计时器而不是 AbortSignal.timeout():后者的定时器是 unref 的,事件循环里没有别的
63
+ // 句柄时它会随循环一起退出,pending 的 promise 就再也不会结算(Node 20 的测试进程里正好如此)。
64
+ const signals = [];
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
+ }
72
+ if (signal) signals.push(signal);
73
+
74
+ const init = { method, headers: headersFor(config, { json: body !== undefined }) };
75
+ if (body !== undefined) init.body = JSON.stringify(body);
76
+ if (signals.length === 1) init.signal = signals[0];
77
+ else if (signals.length > 1) init.signal = AbortSignal.any(signals);
78
+
79
+ try {
80
+ const response = await fetchImpl(joinUrl(base, path), init);
81
+ if (!response.ok) return { ok: false, status: response.status, reason: "http_" + response.status };
82
+ if (expect === "none") return { ok: true, status: response.status, body: null };
83
+ return { ok: true, status: response.status, body: await response.json() };
84
+ } catch (error) {
85
+ const name = error?.name;
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);
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rennimars/dsh-weknora-memory",
3
- "version": "0.2.1",
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",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
+ "writers",
14
15
  "cordis.patch.yml",
15
16
  "wk-inject.config.example.json",
16
17
  "README.md",
@@ -0,0 +1,24 @@
1
+ # writers/ — 写入侧(可选)
2
+
3
+ 本目录是**参考实现**,不随插件默认启用,安装后不会注册任何计划任务。它把会话转录、结论、事实卡状态与画像写进 WeKnora 与 `data/profile.json`;插件的读侧(每轮注入)只读这些东西。
4
+
5
+ ## 契约(四个脚本都必须遵守)
6
+
7
+ 1. **配置**:默认读本目录的 `writer.config.json`,`--config <path>` 可覆盖;缺文件时用 `config.example.json` 的默认值并在日志里说明。
8
+ 2. **凭证**:`apiUrl` 形如 `http://127.0.0.1:8080/api/v1`(**含**版本前缀,与插件配置一致);`apiKey` 留空,从环境变量 `WEKNORA_API_KEY` 取;环境变量优先。
9
+ 3. **HTTP**:一律通过 `../lib/weknora.js` 的 `request()`,不自己写 fetch。
10
+ 4. **路径**:`profile.path` / `profile.statsPath` 相对**包根目录**(本目录的上一级)解析,与插件一致。
11
+ 5. **演练**:每个脚本都支持 `--dry-run`(只打印计划,不写库、不写文件)与 `--verbose`。
12
+ 6. **退出码**:0 成功;非 0 失败。失败必须写日志并返回非 0,不能静默。
13
+ 7. **幂等**:同一轮重复执行不得产生重复文档(按标题前缀 + 内容哈希判定)。
14
+
15
+ ## 脚本
16
+
17
+ | 文件 | 触发建议 | 作用 |
18
+ |---|---|---|
19
+ | `ingest.mjs` | 每小时 | 会话转录原样入库① |
20
+ | `distill.mjs` | 每日 04:30 | 会话 → 结论 → 库② |
21
+ | `dream.mjs` | 每日 03:30 | 事实卡裁决(S0..S4) |
22
+ | `profile-writer.mjs` | 紧接 dream(rc=0 后) | 画像唯一写入者 |
23
+
24
+ `register-tasks.ps1` 按上面的触发时间注册计划任务(幂等,`--unregister` 反注册);`run-all.cmd` 手动跑一轮。
@@ -0,0 +1,57 @@
1
+ {
2
+ "$comment": "Writers config. Copy to writer.config.json next to this file. No credential belongs in this file: leave apiKey out and set WEKNORA_API_KEY in the environment.",
3
+ "apiUrl": "http://127.0.0.1:8080/api/v1",
4
+ "apiKey": "",
5
+ "sessionsRoot": "",
6
+ "kbs": {
7
+ "session": "",
8
+ "knowledge": "",
9
+ "material": ""
10
+ },
11
+ "ingest": {
12
+ "enabled": true,
13
+ "titlePrefix": "会话记录 ",
14
+ "settleMinutes": 10,
15
+ "maxDocBytes": 200000,
16
+ "maxSessionDocs": 150,
17
+ "skipSubagentSessions": true,
18
+ "maxAttempts": 3,
19
+ "pollTimeoutSec": 600
20
+ },
21
+ "distill": {
22
+ "enabled": true,
23
+ "titlePrefix": "记忆 ",
24
+ "maxSessionsPerRun": 3,
25
+ "maxDocsPerRun": 3,
26
+ "maxInputChars": 30000,
27
+ "perTurnChars": 1500,
28
+ "maxEntriesPerSession": 8,
29
+ "semanticDedupe": {
30
+ "enabled": true,
31
+ "scoreMin": 0.66
32
+ }
33
+ },
34
+ "dream": {
35
+ "enabled": true,
36
+ "titlePrefix": "[fact] ",
37
+ "maxLlmCalls": 60,
38
+ "maxUpdates": 20,
39
+ "grouping": {
40
+ "searchTopK": 5,
41
+ "queryChars": 120,
42
+ "sameRootScore": 0.5,
43
+ "crossRootScore": 0.6,
44
+ "maxCluster": 5
45
+ }
46
+ },
47
+ "profile": {
48
+ "enabled": true,
49
+ "path": "data/profile.json",
50
+ "statsPath": "data/profile-stats.json"
51
+ },
52
+ "llm": {
53
+ "mode": "weknora-session-chat",
54
+ "modelName": "builtin-deepseek-flash",
55
+ "timeoutMs": 240000
56
+ }
57
+ }