@rennimars/dsh-weknora-memory 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 写入侧随包发布(可选组件)
4
+
5
+ - **新增 `lib/weknora.js`**:读侧与写入侧共用的唯一 HTTP 客户端(base URL 拼接、`X-API-Key` 头、超时、`no_api_url|timeout|transport|http_<status>` 错误词表)。`lib/retrieve.js` 的两处内联 fetch 已改为调用它,59 个既有用例保持全绿。
6
+ - **新增 `writers/`(默认不启用)**:`ingest.mjs` 会话原文入库①、`distill.mjs` 结论蒸馏入库②、`dream.mjs` 事实卡裁决 S0–S4、`profile-writer.mjs` 画像唯一写入者。四者都支持 `--config` / `--dry-run` / `--verbose`,失败返回可读错误与明确退出码,`--dry-run` 不写库、不写文件、不写 state。
7
+ - **新增 `writers/register-tasks.ps1`** 与 **`writers/run-all.cmd`**:幂等注册/撤销计划任务(当前用户级,不需要管理员),以及手动跑一轮(可选单步)。
8
+ - **凭证纪律**:`writer.config.json` 的 `apiKey` 字段被忽略,只从 `WEKNORA_API_KEY` 环境变量读取;发布包内不含任何密钥、知识库 id 或本机绝对路径。
9
+ - **新增测试 `test/writers.test.mjs`(5 项)**:import 零副作用、`--help` 退出 0、桩地址 dry-run 不落盘且错误可读、`proposed→active`/`degrade→disputed` 只改状态字段、发布树无凭证/无机器路径。总计 64 个用例。
10
+ - 安装后**不会**自动注册任何计划任务,读侧行为与 0.2.2 完全一致。
11
+
12
+ ## 0.2.2 — 补上写入侧(文档)
13
+
14
+ - 新增「写入侧:这些记忆是谁写进去的」一节:三条管道(每小时会话原文入库① / 每日 04:30 结论蒸馏入库② / 每日 03:30 夜间做梦裁决事实卡)与画像写入器的闸门(`proposed` → 隔夜复核 → `active`,被标记 degrade 的卡转 `disputed`),含触发时间、配额与失败策略。
15
+ - 明确边界:这些定时任务属于**宿主侧**,不随本包分发;装上插件不会自动获得写入能力。写清这点是为了不让"记忆插件"的想象越界成实际行为。
16
+ - 补三库分工(原文 / 结论 / 原始材料)与"为什么写入必须独立于读侧"的取舍说明。
17
+ - 代码与默认配置未改动。
18
+
19
+ ## 0.2.1 — 介绍重写(仅文档)
20
+
21
+ - README 从“功能清单”改写为**机制说明**:三层上下文(常驻 / 画像 / 检索)各自的独立预算、一轮之内的完整检索管线(分库并发 → RRF → 来源权重 → 时效衰减 → 冷热分层 → 阈值 → 会话去重 → 邻块扩展 → 事实卡冲突 → 渲染),以及每处设计取舍的理由。
22
+ - 新增「它要解决的四类结构性失效」与「问题 → 机制」对照,明确本插件是**注入治理层**而非又一个 RAG 检索器。
23
+ - 补充硬预算契约(`TIMEOUT_CAP_MS` 4000 / `CHARS_CAP` 2400)、状态文件不依赖数据库的原因(Postgres 不暴露端口、`chunks.metadata` 全 NULL)、禁止双重惩罚、子代理不检索等边界说明。
24
+ - 包 `description` 与 `keywords` 同步改写(面向市场的定位句)。
25
+ - 安装段修正仓库作用域大小写(`@RenniMars` → `@rennimars`)。
26
+ - 代码与默认配置未改动。
27
+
3
28
  ## 0.2.0 — first public release
4
29
 
5
30
  - **每轮检索注入**:把 WeKnora 命中的片段作为独立的 `<weknora_context>` 消息注入,带字符上限、分数阈值与每个库的权重。
@@ -8,4 +33,4 @@
8
33
  - **面板走宿主同源路由**:注册 `/api/weknora-memory/state`(读)与 `/api/weknora-memory/card`(写)两条精确 Fetch 路由,没有第二个端口、没有 CORS、没有令牌同步。
9
34
  - **客户端不依赖 React hooks**:槽位以普通函数调用渲染,hook 会抛 React error #321;面板改为模块级 store + class 生命周期。
10
35
  - **配置从环境变量取凭证**:`WEKNORA_API_URL` / `WEKNORA_API_KEY` 优先于配置文件,发布包里不带任何密钥与知识库 id。
11
- - 任意失败(后端不可达、超时、配置缺失)一律静默降级,只影响这一轮的注入。
36
+ - 任意失败(后端不可达、超时、配置缺失)一律静默降级,只影响这一轮的注入。
package/README.md CHANGED
@@ -1,26 +1,139 @@
1
1
  # dsh-weknora-memory
2
2
 
3
- 把 **WeKnora** 变成 DeepSeek Harness(dsh)的长期记忆:每轮自动检索本机知识库并注入受控长度的上下文,外加一层「常驻画像卡」和一个可在 GUI 里直接编辑的侧边面板。
3
+ **DeepSeek Harness 的分层上下文引擎。** 把本机 WeKnora 知识库变成一份可预算、可审计、可编辑的长期上下文:每一轮只放进去"该进去的那一小段",并且能说清它为什么进去、占了多少字符、什么时候会退场。
4
4
 
5
- > A WeKnora-backed memory layer for DeepSeek Harness: per-turn retrieval injection, standing profile cards, and a sidebar panel to edit them.
5
+ > A layered context engine for DeepSeek Harness — budgeted injection, recency decay, hot/warm/cold tiering, standing profile cards and fact-conflict surfacing on top of a local WeKnora instance.
6
6
 
7
- ## 它做什么
7
+ 它不是"再来一个 RAG 检索器"。检索只是这条管线里的一环,真正决定回答质量的是注入治理:**什么能进、进多少、以什么身份进、什么时候该退场**。
8
8
 
9
- 1. **每轮检索注入** —— 在每一步请求前用当前提问去检索你配置的 WeKnora 知识库,把命中的片段作为一条独立的 `<weknora_context>` 消息追加进去(不改你的原话),并有字符上限与阈值,绝不撑爆上下文。
10
- 2. **常驻卡片** —— 一小批「永不参与打分、每轮必带」的卡片:行为准则(soul)、用户画像(user)、工作习惯(agent)。有分层配额(user 3 张/200 字、agent 6 张/400 字、soul 6 张/300 字),超了就按优先级丢。
11
- 3. **侧边面板** —— GUI 侧边栏多一个「记忆」页签:改卡片正文、启用/停用、新增、删除;看本轮到底注入了什么、哪些卡被哪道闸挤掉、检索命中率/耗时/每库贡献。
9
+ ---
12
10
 
13
- 失败一律静默降级:WeKnora 挂了、超时、配置错了,都只是这一轮不注入,绝不影响会话。
11
+ ## 一、它要解决的四类结构性失效
14
12
 
15
- ## 依赖
13
+ | 失效 | 表现 | 本插件的对策 |
14
+ |---|---|---|
15
+ | **会话失忆** | 换个会话就要重新交代背景,模型对你的偏好、项目约束一无所知 | 每轮自动检索并注入,记忆不依赖模型主动调用 |
16
+ | **检索噪声反噬** | top-k 直接塞进上下文,三条无关片段挤走一条真正相关的 | 字符/条数/阈值三重闸 + 冷热配额,席位有限且按价值分配 |
17
+ | **记忆不可控** | 记忆写死在系统提示词里,改不了、看不见、不知道它今天带了什么 | 侧边面板直读直写,带预算条与命中统计;每轮实际注入内容全程可见 |
18
+ | **旧材料永久占坑** | 三个月前的会话记录和昨天的结论同权,反复抢占 3 个注入席位 | 时效衰减 + 冷热分层,旧片段需要新的证据才能重新入场 |
19
+
20
+ ## 二、三层上下文,三份互不侵占的预算
21
+
22
+ | 层 | 内容 | 预算(默认值) | 打分 / 衰减 |
23
+ |---|---|---|---|
24
+ | **常驻层** `pinned_constraints` | 行为准则、硬约束 | 12 条 · 1500 字符 · 单条 ≤200 字符 | 永不参与打分、永不衰减 |
25
+ | **画像层** `profile` | soul 规则 / user 画像 / agent 工作习惯 | soul 6 张·300 字 · user 3 张·200 字 · agent 6 张·400 字,单卡 ≤80 字 | 永不打分,按类配额裁剪 |
26
+ | **检索层** `weknora_context` | 本轮检索命中的片段 | ≤2400 字符 · 最多 3 条 | 打分 + 衰减 + 分层共同决定 |
27
+
28
+ 三份预算是**相加而不是共享**的:检索块撑满 2400 字符也不会挤掉常驻准则(`lib/config.js:26,31`);画像层按类型分账,避免 user 卡把 agent 层吃空(`lib/config.js:104-110`)。
29
+
30
+ 上限是硬编码契约,不是建议值:`timeoutMs` 被钳到 **4000ms**、`maxChars` 被钳到 **2400**(`lib/config.js:19,26` 的 `TIMEOUT_CAP_MS` / `CHARS_CAP`),手改配置文件也撑不破。
31
+
32
+ ## 三、一轮里到底发生了什么
33
+
34
+ ```
35
+ agent/pre-step
36
+ ├── 查询构造 取本轮用户文本;8–500 字符,过短追问拼上一轮(mergeContextQuery)
37
+ ├── 分库并发检索 每个 kb 一次 /knowledge-search,共享同一 deadline
38
+ ├── RRF 融合 k=60,跨库合并,不让单个库垄断席位(mergeKbGroups)
39
+ ├── 来源权重 sourceWeights 正则 × 系数(默认 ^hs- ×0.35,降权但不删除)
40
+ ├── 时效衰减 w = min(1, max(0.35, 2^(-ageDays/30))) (decay.js)
41
+ ├── 冷热分层 hot ≤14d · warm ≤90d · cold >90d,coldTopK=0
42
+ ├── 阈值闸 minScore 0.1 且 ≥ 本轮最高分的 45%
43
+ ├── 会话去重 本会话已注入过的 chunk 不再重复占用席位
44
+ ├── 邻块扩展 ±1 个相邻 chunk,400ms 子预算,把碎片补成完整段落
45
+ ├── 事实卡 同 subject 多张在效卡 → 块尾追加一行「⚠ 冲突未决」;superseded ×0.3
46
+ └── 渲染 以独立来源消息追加(source.kind = plugin:weknora-inject)
47
+ ```
48
+
49
+ 最后一步的细节很重要:注入块是**一条独立的 user-role 消息**,不是对你的提问的改写,模型能分辨"这是检索来的历史材料"而不是"用户刚说的新任务"。
50
+
51
+ ## 四、写入侧:这些记忆是谁写进去的
52
+
53
+ 本包只做**读**(每轮注入)和**人工编辑**(面板)。把原文、结论、画像写进 WeKnora 与 `data/profile.json` 的,是宿主侧的定时任务——**装上本插件不会自动获得这些任务**,它们是同一套体系的另一半,这里把契约写清楚,方便你自己实现或用别的工具替代。
54
+
55
+ | 任务 | 触发 | 做什么 | 写到哪里 |
56
+ |---|---|---|---|
57
+ | `DSH-Session-Ingest` | 每小时 | 把**已结束且空闲 ≥10 分钟**的会话转录**原样**入库,跳过子代理会话;单篇 ≤200,000 字符,最多保留 150 篇;失败重试 3 次,入库后轮询索引 ≤600 秒 | 库① 会话记忆 |
58
+ | `DSH-Session-Distill` | 每日 04:30 | 从最近会话里抽取结论 / 约束 / 偏好 / 教训,一次最多 3 个会话、3 篇文档,输入 ≤30,000 字符、每轮 ≤1500 字符、每会话 ≤8 条,语义去重阈值 0.66 | 库② 知识 |
59
+ | `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`,下一轮起不再进常驻块 | 常驻层画像 |
61
+ | `DSH-Memory-Weekly` | 每周 | 体检:跨库重复、失效文档、状态异常 | 三库 |
62
+
63
+ 三库的分工不是随手划的:
64
+
65
+ - **库① 会话记忆**存原文,回答"当时到底怎么说的";
66
+ - **库② 知识**存结论,一条结论一张事实卡,回答"这件事定了没有、为什么";
67
+ - **库③ 资料**存你手工入库的原始文件,回答"这份规范里怎么写的"。
68
+
69
+ **为什么写入侧必须独立于插件:** 写要 LLM、要数据库权限、要能被人工审计;而读侧每轮都跑,必须零依赖、可静默失败。两者混在一起,任何一次写入故障都会变成一次会话故障。
70
+
71
+ **为什么画像文件只允许一个写入者:** 常驻内容每轮注入、跨会话传播——一旦被污染,后面没有任何一次检索能把它纠正回来。所以插件侧只读,任何新内容只能走 `proposed → 隔夜复核 → active` 这条闸门。
72
+
73
+ ### 本包自带的写入侧参考实现(`writers/`,默认不启用)
74
+
75
+ 上面那张表是**它应该长什么样**;本包把同一套约定做成了可复制的实现,放在 `writers/`,**随包分发但默认不运行**——装上插件不会创建任何计划任务、不会写任何文档。
76
+
77
+ | 文件 | 作用 |
78
+ |---|---|
79
+ | `writers/ingest.mjs` | 会话转录 → 库①(幂等:同名文档走 PUT 复用 id) |
80
+ | `writers/distill.mjs` | 会话 → 结论 → 库②(含语义去重,LLM 走 WeKnora 会话网关) |
81
+ | `writers/dream.mjs` | 事实卡裁决 S0–S4,只改 header 状态;`--self-test` 可离线跑 |
82
+ | `writers/profile-writer.mjs` | `data/profile.json` 的唯一写入者;不带 `--write` 就只打印 diff |
83
+ | `writers/register-tasks.ps1` | 幂等注册/更新计划任务(`-Unregister` 撤销,`-Prefix`、`-Times` 可改) |
84
+ | `writers/run-all.cmd` | 手动跑一轮:`run-all.cmd [--dry-run] [ingest|distill|dream|profile]` |
85
+
86
+ 三步启用:
87
+
88
+ ```powershell
89
+ cd node_modules/@rennimars/dsh-weknora-memory
90
+ copy writers\config.example.json writers\writer.config.json # 填 apiUrl / sessionsRoot / kbs
91
+ $env:WEKNORA_API_KEY = "<你的 key>" # 只从环境变量读,不落文件
92
+ powershell -ExecutionPolicy Bypass -File writers\register-tasks.ps1
93
+ ```
94
+
95
+ 四条纪律:
96
+
97
+ - **凭证只在环境变量**:`writer.config.json` 里的 `apiKey` 字段会被忽略;缺 `WEKNORA_API_KEY` 时只提示,不带该请求头。
98
+ - **演练零成本**:四个脚本都支持 `--dry-run`(不写库、不写文件、不写 state);`dream.mjs --dry-run --no-llm` 连一次 LLM 都不发。
99
+ - **共用一份 HTTP**:读侧 `lib/retrieve.js` 与写入侧全部走 `lib/weknora.js`,`X-API-Key` 头与错误词表只有一处实现。
100
+ - **失败可见**:退出码 0/1/2/3 有明确语义,失败是一条可读错误而不是异常栈——这与读侧「失败即静默」刻意相反,因为定时任务没有人在看着。
101
+
102
+ ## 五、几个刻意的设计取舍
103
+
104
+ **不改用户原话。** 注入永远走追加,避免任何"提示词污染"式的隐蔽改写。
105
+
106
+ **状态不依赖数据库。** WeKnora 的 Postgres 不对宿主暴露端口,且实测 `chunks.metadata` 4950 行全为 NULL——想往库里写 tier 或命中时间根本无处可写(`decay.js` 文件头注释)。所以状态落在插件自己的 `data/tier-state.json`:tmp 写入 + rename 原子替换,上限 4000 条,读-合-写是一个同步序列,同进程两个写者不会交错。
107
+
108
+ **冷启动不误伤。** hotDays 14 / warmDays 90 是"先测量"的值:今天入库的每一块都还年轻,全部算 hot,分层层在真实年龄积累起来之前是一个空操作;唯一立即改变行为的开关是 `coldTopK=0`,它只挡"又旧又从没命中过"的块(`tier.js:38-46`)。
109
+
110
+ **禁止双重惩罚。** 已经被 `sourceWeights` 降过权的命中,不再叠加衰减(`applyToSourceWeights=false`):惩罚只施加一次,日志里写明是哪一种。
111
+
112
+ **失败即静默。** 后端挂了、超时、配置写错——全部返回未改动的 decision,这一轮只是不注入。会话不会因为记忆层而失败或卡住(`index.js` 文件头注释)。
113
+
114
+ **自动写入与人工编辑分权。** `data/profile.json` 的自动更新属于夜间任务,插件本身只读;面板里的编辑是用户显式动作。这条边界防的是"每轮注入、每轮重算"的自我强化——一个被污染的事实否则会跨会话传播。
115
+
116
+ ## 六、可观测性
117
+
118
+ 侧边栏「记忆」页签,三个视图:
119
+
120
+ - **记忆卡** —— 编辑正文 / 启停 / 新增 / 删除,每条都带预算条(用了多少字符、离上限多远)
121
+ - **记忆统计** —— 每张卡的轮次与命中次数
122
+ - **检索统计** —— 注入率、平均命中数、耗时 p50/p95、每个知识库的贡献占比
123
+
124
+ 面板走**同源路由**:宿主注册 `/api/weknora-memory/state`(读)与 `/api/weknora-memory/card`(写),浏览器用相对路径调用——没有第二个端口、没有 CORS、没有令牌要同步。客户端脚本无构建、无 hooks(slot 渲染是普通函数调用,用模块级 store + class 组件),装完即用。
125
+
126
+ 日志只记元数据:会话 id、耗时、命中数、字符数、结果。**不写提问原文,也不写片段正文**。
127
+
128
+ ## 七、依赖
16
129
 
17
130
  | 项 | 要求 |
18
131
  |---|---|
19
132
  | DSH | `>= 0.2.1-rc.3`(用到 GUI 的 `/api` Fetch 路由与 `sidebar.panellist` 槽) |
20
133
  | Node | `>= 20.11` |
21
- | WeKnora | 一个可访问的实例(默认 `http://127.0.0.1:8080/api/v1`),以及至少一个知识库 |
134
+ | WeKnora | 一个可访问实例(默认 `http://127.0.0.1:8080/api/v1`),以及至少一个知识库 |
22
135
 
23
- ## 安装
136
+ ## 八、安装
24
137
 
25
138
  **从插件市场**:插件页 → 插件市场 → 搜索 `weknora` → 安装。
26
139
 
@@ -28,13 +141,13 @@
28
141
 
29
142
  ```bash
30
143
  cd "$DSH_HOME/profiles/<你的 profile>"
31
- mkdir -p node_modules/@RenniMars
144
+ mkdir -p node_modules/@rennimars
32
145
  git clone https://github.com/RenniMars/dsh-weknora-memory node_modules/@rennimars/dsh-weknora-memory
33
146
  ```
34
147
 
35
- 然后在同目录的 `package.json` 的 `dsh.profile.bundles` 里加一行 `"@rennimars/dsh-weknora-memory"`,重启客户端。
148
+ 然后在同目录 `package.json` 的 `dsh.profile.bundles` 里加一行 `"@rennimars/dsh-weknora-memory"`,重启客户端。
36
149
 
37
- ## 配置
150
+ ## 九、配置
38
151
 
39
152
  复制模板,填三样东西:
40
153
 
@@ -54,33 +167,36 @@ cp wk-inject.config.example.json wk-inject.config.json
54
167
  }
55
168
  ```
56
169
 
57
- - **apiKey 建议不要写进文件**:留空,用环境变量 `WEKNORA_API_KEY`(以及可选 `WEKNORA_API_URL`)注入。环境变量优先于文件,所以同一份配置可以在不同机器上用不同的 key。
170
+ - **apiKey 建议不要写进文件**:留空,用环境变量 `WEKNORA_API_KEY`(可选 `WEKNORA_API_URL`)注入。环境变量优先于文件,同一份配置可在不同机器上用不同 key。
58
171
  - `kbIds` 就是检索范围;`kbNames` 只影响注入块里显示的中文标签。
59
- - 模板默认 `enabled: false`,填好再改成 `true`(或直接删掉这一行,默认开启)。
60
-
61
- 其余参数(超时、字符上限、命中数、阈值、衰减、分层配额、日志路径)见 `lib/config.js` 的 `DEFAULT_CONFIG`,注释齐全;一般不用动。
62
-
63
- ## 面板
172
+ - 模板默认 `enabled: false`,填好再改成 `true`(或删掉这一行,默认开启)。
64
173
 
65
- 侧边栏「记忆」→ 三个页签:**记忆卡**(编辑/启停/新增/删除,带预算条)、**记忆统计**(每张卡的轮次与命中)、**检索统计**(注入率、平均命中、耗时 p50/p95、每库贡献)。
174
+ 其余参数(超时、字符上限、命中数、阈值、RRF k、邻块扩展、衰减半衰期与下限、冷热分层配额、画像卡配额、事实卡权重、日志路径)见 `lib/config.js` 的 `DEFAULT_CONFIG`,每一项都带注释说明为什么取这个值;一般不用动。
66
175
 
67
- 面板是**同源路由**:宿主注册 `/api/weknora-memory/state`(读)与 `/api/weknora-memory/card`(写),浏览器用相对路径调用,没有第二个端口、没有 CORS、没有令牌要同步。
176
+ ## 十、隐私与边界
68
177
 
69
- ## 隐私
70
-
71
- - 面板与卡片读写的 `data/profile.json`、`data/profile-stats.json`、`logs/inject.log` 全部**留在本机**,不进仓库、不进包(`.gitignore` / `.npmignore` 都排除了)。
72
- - 日志只写元数据(会话 id、耗时、命中数、字符数、结果),**不写提问原文,也不写片段正文**。
178
+ - `data/profile.json`、`data/profile-stats.json`、`data/tier-state.json`、`logs/inject.log` 全部留在本机,不进仓库、不进包(`.gitignore` / `.npmignore` 都排除了)。
73
179
  - 本插件不向任何第三方上报数据;所有请求只发往你自己配置的 `apiUrl`。
180
+ - 子代理会话默认不检索(`skipSubagents: true`):子代理拿到的是被裁剪过的任务,用它的文本去检索只会污染主会话的记忆。
74
181
 
75
- ## 开发
182
+ ## 十一、开发
76
183
 
77
184
  ```bash
78
185
  npm test # node --test test/*.mjs,59 个用例
79
186
  ```
80
187
 
81
- - `lib/index.js` 入口(`apply`:注册 `agent/pre-step` + 挂面板路由)
82
- - `lib/injector.js` 注入装配 · `lib/retrieve.js` WeKnora 调用 · `lib/format.js` 选择与渲染 · `lib/config.js` 配置与日志 · `lib/panel.js` 面板路由 · `lib/client.js` GUI 侧面板(无构建、无 hooks)
83
- - 内部运维笔记不随仓库发布。
188
+ | 文件 | 职责 |
189
+ |---|---|
190
+ | `lib/index.js` | 入口:`apply` 注册 `agent/pre-step` + 挂面板路由 |
191
+ | `lib/injector.js` | 注入装配:身份判定、查询构造、去重、消息组装 |
192
+ | `lib/retrieve.js` | WeKnora 分库并发检索、RRF 融合、来源权重、邻块扩展 |
193
+ | `lib/format.js` | 阈值筛选、事实卡解析、冲突行、受控块渲染 |
194
+ | `lib/tier.js` | hot/warm/cold 分层与回温规则 |
195
+ | `lib/decay.js` | 时效衰减与命中回写(原子 JSON 状态) |
196
+ | `lib/config.js` | 配置加载、钳制、日志 |
197
+ | `lib/panel.js` / `lib/client.js` | 面板同源路由 / GUI 侧渲染 |
198
+ | `lib/weknora.js` | 唯一 HTTP 客户端:读侧与写入侧共用 |
199
+ | `writers/*.mjs` | 可选写入侧:ingest / distill / dream / profile-writer |
84
200
 
85
201
  ## License
86
202
 
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,80 @@
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
+ const signals = [];
63
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) signals.push(AbortSignal.timeout(timeoutMs));
64
+ if (signal) signals.push(signal);
65
+
66
+ const init = { method, headers: headersFor(config, { json: body !== undefined }) };
67
+ if (body !== undefined) init.body = JSON.stringify(body);
68
+ if (signals.length === 1) init.signal = signals[0];
69
+ else if (signals.length > 1) init.signal = AbortSignal.any(signals);
70
+
71
+ try {
72
+ const response = await fetchImpl(joinUrl(base, path), init);
73
+ if (!response.ok) return { ok: false, status: response.status, reason: "http_" + response.status };
74
+ if (expect === "none") return { ok: true, status: response.status, body: null };
75
+ return { ok: true, status: response.status, body: await response.json() };
76
+ } catch (error) {
77
+ const name = error?.name;
78
+ return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "timeout" : "transport" };
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rennimars/dsh-weknora-memory",
3
- "version": "0.2.0",
4
- "description": "WeKnora-backed long-term memory for DeepSeek Harness (dsh): per-turn retrieval injection, standing profile cards, and a sidebar panel to edit them.",
3
+ "version": "0.3.0",
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",
7
7
  "exports": {
@@ -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",
@@ -23,9 +24,12 @@
23
24
  "dshmarket",
24
25
  "deepseek-harness",
25
26
  "memory",
27
+ "long-term-memory",
28
+ "context-engineering",
26
29
  "weknora",
27
30
  "rag",
28
- "retrieval"
31
+ "retrieval",
32
+ "rrf"
29
33
  ],
30
34
  "license": "MIT",
31
35
  "author": "RenniMars",
@@ -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
+ }