dsh-prime-memory 0.26.16 → 0.26.18

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,42 @@
1
1
  # 更新日志(Changelog)
2
2
 
3
+ ## [未发布] — 待并入下一版
4
+
5
+ - **#20 加固(属性测试 + 形状一致)**:新增 `resolveDrscBaseUrl` 优先级的确定性属性测试(固定种子 mulberry32,400 + 300 组随机组合),钉住四条不变式:显式值恒为终值、有非空 trustedHosts 条目时 origin 不得抢位、origin 只替换那句 8080、前两档之外的来源一律不回显 Host 头。**该测试首遍即抓出一处真实形状不一致**:显式值/条目分支会给裸 authority 补 `http://` 前缀,而新增的 origin 分支原样透传不补——若调用方递的是 `127.0.0.1:<port>` 这类 authority(而非宿主 `getOrigin()` 返回的全 URL),就会拼出无 scheme 的基址。已抽单一 `withScheme()` 让三档共用同一规范化。
6
+ - **有效性已红绿验证**:临时把 origin 抬到 trustedHosts 之上(正是计划里判为回归的那个错误改法)后,5 例转红;恢复即全绿。全量 **1886 例**/188 文件。
7
+
8
+ ## [0.26.18] — 2026-11-02
9
+
10
+ > **Room 词表真计数修复(issue #27):注册条目不再显示「现有 0 条」。本版会改变 LLM 归类分布,独立成版以给回滚位与观察窗。**
11
+
12
+ ### 修复
13
+
14
+ - **注册条目的计数从 `listRooms()` 回查**:候选预标记(`annotateOrphanCandidates`)合并词表时,注册表条目被写死 `count: 0`,而未注册的自生长项带真计数。归类器被要求「优先从现有 Room 词表里选」,而计数是它唯一能看到的规模证据——证据方向与规则**恰好相反**:按推荐用法先 pre-register 一张词表,这张词表里每一项就立即变成空壳,历史上自生长出来的碎片 slug 反而看着像主仓。净效果正是 annotate 本来要防的那件事:词表碎片化。`?? 0` 保留原语义——只有「注册了但库里确实零绑定」的新 Room 才显示 0(那正是这个 0 本来想表达的意思)。
15
+ - **取数口径单点化**:同一句「注册表条目的真实计数」本仓写了四处,其中三处已是对的形态(`stats.ts` rooms-export、`tools` room-admin list、以及候选词表本该有的形态),只有候选词表这处写死成 0——有本地定义就有分叉位。抽 `roomCountsBySlug()` 供 annotate / room-admin list / rooms-export 三处共用,顺手消除 room-admin list 里对每个注册条目的线性 `find`(上限 200 条注册表 × N 个 Room)。`stats.ts:1044` 的 rooms-get 条目形态带 `source`、排序 grown 优先且计数本就没丢,与词表不同形,故未强并以免改到面板输出形状。
16
+ - **补 count 路径测试两例**:`tests/room-review.test.ts` 原先两个 annotate 用例都注入自己的 annotator 且忽略 catalog、`io()` 默认不带 registry,**count 路径零覆盖**——这是写死 0 能活过 5 个版本的直接原因。新增「注册条目回查真计数」(修复前必红,实测报 `expected +0 to be 1`,与报告人自述的红绿结果逐字一致)与「注册零绑定仍为 0」(锁住不误伤原语义),另加 `roomCountsBySlug` 口径一例。
17
+
18
+ ### 行为边界(说清它不只是个计数)
19
+
20
+ - 本修复会改变发给归类器的 prompt 内容,因而**可能改变归类分布**:大 Room 拿到更强信号后可能更抑新 slug 生长(冷启动无处归)。这是修复目的而非纯 bug,故独立成版;若观察到新 Room 实在挂不上,revert 本版单个 commit 即可,不牵连 0.26.17 的三条声明面修复。
21
+ - **未一并做**(另立案,原因记在计划产物里):把注册表的 `label`/`description` 渲染进 prompt、调 `.slice(0, 40)` 窗口。annotate 是逐条一次 LLM 调用且无 prompt 前缀缓存(同一张词表作为 system 被重复发 40 次),把窗口抬到 120 并塞 label+description 会让每轮仅词表就吃掉 14-24 万 input token,需先有归类分布的可观测指标。
22
+ - 全量 **1883 例**/188 文件绿(本版在 0.26.17 的 1880 例之上加 3 例)。
23
+
24
+ ## [0.26.17] — 2026-11-02
25
+
26
+ > **三条声明面修复:`memory:profile` 显式关插值(issue #24)、`memory_conflicts` 补两笔成熟度声明(issue #23)、DRSC 基址不再写死 8080(issue #20)**,并把「改视图忘改 schema」从注释纪律改成 CI 闸。无配置变更、无默认行为变更、`engines.dsh` 不动。
27
+
28
+ ### 修复
29
+
30
+ - **`memory:profile` 关插值(#24)**:宿主对 systemPrompt 贡献项的 `interpolate` **缺省按 true 处理**,而画像正文由蒸馏产出、库里可能存着宿主自己发的字面 `{{TYPE_id}}` 脱敏 token——不合 `/^[a-z][a-z0-9_]*$/` 即在 prompt 组装期抛错、**整轮报废**。该正文从来不含宿主变量,插值只有风险没有收益,故注册时显式 `interpolate: false` 按原文注入。全 `src` 实测 `systemPrompt.context/section` 只有这一处注册点。
31
+ - **`memory_conflicts` 补声明(#23)**:Phase 3 arm-2/arm-3 给视图加的两笔成熟度旁注(`maturityOf`)没同步到输出 schema,而该 schema 是 `additionalProperties: false`——宿主无损校验对每条返回逐字段拒结,工具自 0.26.11 起**任何调用都读不出**(`memory_resolve_conflict` 的描述还在把人往它指,公示的「先看后裁决」工作流事实上断链)。补 `winner_maturity`/`loser_maturity` 两键(用 `enum ['low','mid','high']` 钉死 `computeMaturity` 的三档值域)。该文件自己就写着「schema 必须跟视图同步」——纪律在注释里,闸在运行期,两者此前没有任何机械约束连着。
32
+ - **DRSC 基址取宿主真实监听口(#20)**:`trustedHosts` 为空时回退写死 `http://127.0.0.1:8080`,而 DSH Desktop App 走随机端口 → `conversation_search` 与蒸馏背景窗在**默认配置下必坏**(0.26.x 的 L0 正文已停写、历史面完全依赖 DRSC,这条链是 load-bearing 的,不是可选增强)。现在第三档兜底取宿主监听 origin,取数逻辑抽到 `src/clients/web-server-origin.ts`(两条取数路径 + 三个抛错入口,需被单测钉住,而不是靠人肉真机核对)。**优先级与降级面钉死四件事**:① origin 只替换那句 8080,不抢 `trustedHosts` 位——现状 `explicit` 是死分支(插件没有该设置项),唯一真实旋钮是宿主 trustedHosts,放上去会把靠 `--trusted-host` 已能工作的用户改到另一个 authority,那是回归;② **优先 `getOrigin()`、再退 `port`**——两条宿主线不同形,已逐线实测取证,别拿一条线当事实:0.2.0-rc.2 源码仓的 webServer **没有** getOrigin(只有 `get port()`/`get host()`),而 0.2.1-rc.5 装机宿主(app.asar 类体)**已有** `getOrigin()`,返回 `${protocol}//${bindUrlHost(host)}:${listenedPort}`,protocol 认 TLS、bind host 走 URL 形式转换,这两个细节插件自己算不出;③ Host 头只在存在非空 trustedHosts 条目时回显(与旧版逐字同口径)——collector 信任门精确匹配 authority,拿条目原串去配带真实端口的 URL 只会把「端口不对」换成「Host 不被信任」;collector 把路由注册在宿主 webServer 上(同监听口),所以宿主 origin 就是正确基址;④ `getOrigin()` 在未 listen / 绑定地址带 zone id 时**主动抛错**(宿主类体原话:`origin read before the listener bound a port`),退 `port` 时又必须 `typeof port === 'number' && port > 0`(`listenedPort` 未 listen 读到 undefined,naive 拼接会产出 `http://127.0.0.1:undefined` 这种能发出但永远错的 URL)——取不到/抛错一律返回 undefined = **逐字节等价旧行为**。`inject` 未动(`webServer` 已在声明里),故老线零变更、`engines.dsh` 不动。
33
+
34
+ ### 纪律
35
+
36
+ - 新增 `tests/tool-output-view-sync.test.ts`:对读工具**真实执行一次**,把返回对象的键路径拿去对照它自己声明的 `output.schema`,多一个键即红(`additionalProperties: false` 下这正是宿主的拒绝判据)。同文件另扫每个 `systemPrompt.context({`/`section({` 入参块必须显式含 `interpolate`。两条守卫均已做**红绿验证**:临时撤掉 #23/#24 的修复 → 分别报出 `$.items[0].winner_maturity` 与 `src/hooks/recall.ts:892`。
37
+ - 覆盖面边界(文件头已如实标注):只守「未声明的多余键」这一种(即 #23 的形状),不校验值类型/枚举,不含会改库的写侧工具。
38
+ - 全量 **1880 例**/188 文件绿(基线 1863 例/186 文件;本版新增 ests/tool-output-view-sync.test.ts 10 例与 ests/web-server-origin.test.ts 7 例)。
39
+
3
40
  ## [0.26.15] — 2026-10-28
4
41
 
5
42
  > 真宿主验证(1197 条 L1 / 380 条 `work_method` 的真实库副本)发现 0.26.14 的**写放大**缺陷,本版修掉。单测没抱住它,因为夹具只有几条记录。
@@ -17,10 +17,13 @@
17
17
  * 分口径处理:蒸馏背景窗空窗降级(runner),工具面显式报错(tools)。
18
18
  *
19
19
  * 同源 Host 解析(steward drs-source 的 resolveDrsBaseUrl 同款):
20
- * 显式 baseUrl > 首个 trustedHosts 条目(补 http://)> `http://127.0.0.1:8080`
21
- * (宿主缺省口)。Host 头回显首个 trustedHosts 条目:collector 信任门是精确
22
- * 匹配,条目省略端口而 URL 带端口会失配。trustedHosts 惰性读取(webStartup
23
- * 可能晚于本插件就绪),每次请求现算,无缓存陈旧面。
20
+ * 显式 baseUrl > 首个 trustedHosts 条目(补 http://)> 宿主实际监听 origin
21
+ * (issue #20:Desktop App 走随机端口,写死 8080 永远对不上)> `http://127.0.0.1:8080`
22
+ * (宿主缺省口)。Host 头**只回显被选作基址的那个 trustedHosts 条目**:collector
23
+ * 信任门是精确匹配,条目省略端口而 URL 带端口会失配——所以基址来自 origin 兜底时
24
+ * 索性不回显(等价于旧版 trustedHosts 为空时的形状),而不是拿另一个 authority 去撞门。
25
+ * trustedHosts / origin 均惰性读取(webStartup、webServer 可能晚于本插件就绪),
26
+ * 每次请求现算,无缓存陈旧面。
24
27
  */
25
28
  import type { ConversationMessage } from '../types.js';
26
29
  /** collector API 路径(与其 DRS_API_PATH 字面一致;不跨包 import)。 */
@@ -33,11 +36,25 @@ export declare class DrscClientError extends Error {
33
36
  cause?: unknown;
34
37
  });
35
38
  }
39
+ /** 基址与其来源。 */
40
+ export interface ResolvedDrscBase {
41
+ url: string;
42
+ /** 基址是否取自显式配置或 trustedHosts 条目(即解析序的前两档)。 */
43
+ fromConfigured: boolean;
44
+ }
45
+ /** 供 Host 回显的条目:须与 {@link resolveDrscBaseUrl} 的选取口径逐字一致。 */
46
+ export declare function firstDrscTrustedHost(trustedHosts?: readonly string[]): string | undefined;
36
47
  /**
37
- * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
38
- * `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 collector 门本就放行)。
48
+ * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)> 宿主实际监听
49
+ * origin(issue #20 新增的兜底)> `http://127.0.0.1:8080`(宿主默认口;trustedHosts
50
+ * 缺席的老线 collector 门本就放行)。
51
+ *
52
+ * origin 刻意排在 trustedHosts **之下**:它只替换那句写死的 8080。放上去会把
53
+ * 已经能工作的配置(靠 `--trusted-host` 指过地址)改到另一个 authority 上,那是回归。
39
54
  */
40
- export declare function resolveDrscBaseUrl(explicit: string | undefined, trustedHosts?: readonly string[]): string;
55
+ export declare function resolveDrscBaseUrl(explicit: string | undefined, trustedHosts?: readonly string[], fallbackOrigin?: string): string;
56
+ /** {@link resolveDrscBaseUrl} 的带来源形态,供客户端决定 Host 回显。 */
57
+ export declare function resolveDrscBaseUrlDetail(explicit: string | undefined, trustedHosts?: readonly string[], fallbackOrigin?: string): ResolvedDrscBase;
41
58
  /** `op=session` 的一个 segment 行(redact=1 时 text 为脱敏正文;wire 蛇形直取)。 */
42
59
  export interface DrscSessionRow {
43
60
  seq: number;
@@ -79,10 +96,15 @@ export declare function drscRoleOf(kind: string): 'user' | 'assistant' | undefin
79
96
  */
80
97
  export declare function drscRowToConversationMessage(row: DrscSessionRow, sessionId: string): ConversationMessage | undefined;
81
98
  export interface DrscClientOptions {
82
- /** 显式基址(缺省走 trustedHosts → 127.0.0.1:8080,见 resolveDrscBaseUrl)。 */
99
+ /** 显式基址(缺省走 trustedHosts → 宿主 origin → 127.0.0.1:8080,见 resolveDrscBaseUrl)。 */
83
100
  baseUrl?: string;
84
101
  /** trustedHosts 惰性读取(webStartup 服务可能晚于装配;每次请求现取)。 */
85
102
  trustedHosts?: () => readonly string[] | undefined;
103
+ /**
104
+ * 宿主实际监听 origin 惰性读取(issue #20)。webServer 服务可能晚就绪/换实例/
105
+ * 下线,故与 trustedHosts 同款每次现取;取不到即返回 undefined → 落回 8080 老行为。
106
+ */
107
+ origin?: () => string | undefined;
86
108
  /** 注入面(单测);缺省全局 fetch。 */
87
109
  fetchImpl?: typeof fetch;
88
110
  /** 单请求超时 ms;缺省 {@link DRSC_DEFAULT_TIMEOUT_MS}。 */
@@ -93,8 +115,10 @@ export declare class DrscClient {
93
115
  private readonly fetchImpl;
94
116
  private readonly timeoutMs;
95
117
  constructor(options?: DrscClientOptions);
96
- /** 每次请求现算基址与 Host 回显(trustedHosts 惰性;无缓存陈旧面)。 */
118
+ /** 每次请求现算基址与 Host 回显(trustedHosts/origin 惰性;无缓存陈旧面)。 */
97
119
  private base;
120
+ /** origin 探针失败(服务未就绪/属性读抛)不得影响请求:视作取不到,落老行为。 */
121
+ private safeOrigin;
98
122
  /** 一个 GET 的 JSON 包络(非 2xx / ok!==true / 超时 / 断网一律抛 DrscClientError)。 */
99
123
  private getJson;
100
124
  /**
@@ -9,16 +9,38 @@ export class DrscClientError extends Error {
9
9
  this.name = 'DrscClientError';
10
10
  }
11
11
  }
12
+ /** 供 Host 回显的条目:须与 {@link resolveDrscBaseUrl} 的选取口径逐字一致。 */
13
+ export function firstDrscTrustedHost(trustedHosts) {
14
+ return trustedHosts?.find((entry) => entry.trim() !== '')?.trim();
15
+ }
12
16
  /**
13
- * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
14
- * `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 collector 门本就放行)。
17
+ * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)> 宿主实际监听
18
+ * origin(issue #20 新增的兜底)> `http://127.0.0.1:8080`(宿主默认口;trustedHosts
19
+ * 缺席的老线 collector 门本就放行)。
20
+ *
21
+ * origin 刻意排在 trustedHosts **之下**:它只替换那句写死的 8080。放上去会把
22
+ * 已经能工作的配置(靠 `--trusted-host` 指过地址)改到另一个 authority 上,那是回归。
15
23
  */
16
- export function resolveDrscBaseUrl(explicit, trustedHosts) {
24
+ export function resolveDrscBaseUrl(explicit, trustedHosts, fallbackOrigin) {
25
+ return resolveDrscBaseUrlDetail(explicit, trustedHosts, fallbackOrigin).url;
26
+ }
27
+ /** 条目→URL 的单一规范化:无 scheme 时补 `http://`。三档来源共用,不开两种形状。 */
28
+ function withScheme(authority) {
29
+ return /^https?:\/\//u.test(authority) ? authority : `http://${authority}`;
30
+ }
31
+ /** {@link resolveDrscBaseUrl} 的带来源形态,供客户端决定 Host 回显。 */
32
+ export function resolveDrscBaseUrlDetail(explicit, trustedHosts, fallbackOrigin) {
17
33
  const trimmed = explicit?.trim() ?? '';
18
- const pick = trimmed !== '' ? trimmed : trustedHosts?.find((entry) => entry.trim() !== '')?.trim();
19
- if (pick === undefined || pick === '')
20
- return 'http://127.0.0.1:8080';
21
- return /^https?:\/\//u.test(pick) ? pick : `http://${pick}`;
34
+ const pick = trimmed !== '' ? trimmed : firstDrscTrustedHost(trustedHosts);
35
+ if (pick !== undefined && pick !== '') {
36
+ return { url: withScheme(pick), fromConfigured: true };
37
+ }
38
+ const origin = fallbackOrigin?.trim() ?? '';
39
+ // origin 也走同一规范化:宿主 getOrigin() 返的是全 URL(此处原样透传),但若调用方
40
+ // 递的是 `127.0.0.1:<port>` 这种 authority,不补 scheme 会让拼出的请求 URL 直接不行。
41
+ if (origin !== '')
42
+ return { url: withScheme(origin), fromConfigured: false };
43
+ return { url: 'http://127.0.0.1:8080', fromConfigured: false };
22
44
  }
23
45
  /** kind → L0 角色词表(memory 只收 user/assistant 两类,其余(tool/* 等)不映射)。 */
24
46
  export function drscRoleOf(kind) {
@@ -80,12 +102,28 @@ export class DrscClient {
80
102
  this.fetchImpl = options.fetchImpl ?? fetch;
81
103
  this.timeoutMs = options.timeoutMs ?? DRSC_DEFAULT_TIMEOUT_MS;
82
104
  }
83
- /** 每次请求现算基址与 Host 回显(trustedHosts 惰性;无缓存陈旧面)。 */
105
+ /** 每次请求现算基址与 Host 回显(trustedHosts/origin 惰性;无缓存陈旧面)。 */
84
106
  base() {
85
107
  const trusted = this.options.trustedHosts?.() ?? [];
86
- const base = resolveDrscBaseUrl(this.options.baseUrl, trusted).replace(/\/+$/u, '');
87
- const first = trusted.find((entry) => entry.trim() !== '')?.trim();
88
- return { url: `${base}${DRSC_API_PATH}`, ...(first !== undefined ? { hostHeader: first } : {}) };
108
+ const origin = this.safeOrigin();
109
+ const resolved = resolveDrscBaseUrlDetail(this.options.baseUrl, trusted, origin);
110
+ const base = resolved.url.replace(/\/+$/u, '');
111
+ // Host 头只在**存在非空 trustedHosts 条目**时回显(与旧版逐字同口径)。基址来自
112
+ // origin 兜底时本来就没有条目可回显,而 collector 信任门精确匹配 authority,
113
+ // 拿条目原串去配一个带真实端口的 URL 只会失配(#20 的报错面)。
114
+ const echo = resolved.fromConfigured ? firstDrscTrustedHost(trusted) : undefined;
115
+ return { url: `${base}${DRSC_API_PATH}`, ...(echo !== undefined ? { hostHeader: echo } : {}) };
116
+ }
117
+ /** origin 探针失败(服务未就绪/属性读抛)不得影响请求:视作取不到,落老行为。 */
118
+ safeOrigin() {
119
+ if (this.options.origin === undefined)
120
+ return undefined;
121
+ try {
122
+ return this.options.origin();
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
89
127
  }
90
128
  /** 一个 GET 的 JSON 包络(非 2xx / ok!==true / 超时 / 断网一律抛 DrscClientError)。 */
91
129
  async getJson(query) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * issue #20:宿主实际监听 origin 的降级读取(DrscClient 的第三档兜底)。
3
+ *
4
+ * 为何不写在 index.ts 里:它有两条取数路径 + 三个抛错入口,是需要被单测钉住的可判定
5
+ * 逻辑;埋在装配文件里就只能靠人肉真机核对,而 #20 的整条因果链正是「没人核对过」。
6
+ *
7
+ * 优先取 `getOrigin()`,再退 `port`——两者的可用性已逐线实测取证,别拿一条线当事实:
8
+ * ・0.2.0-rc.2 源码仓的 webServer **没有** getOrigin(),只有 `get port()` / `get host()`;
9
+ * ・0.2.1-rc.5 装机宿主(app.asar 内类体)已有 `getOrigin()`,返回
10
+ * `${protocol}//${bindUrlHost(host)}:${listenedPort}`——protocol 认 TLS、bind host 走
11
+ * URL 形式转换,这两个细节本插件自己算不出来,所以新线一律用它。
12
+ *
13
+ * 三条硬降级理由(缺一就不能发):
14
+ * ① `getOrigin()` 在未 listen / 绑定地址带 zone id 时**主动抛错**(不是返回 undefined),
15
+ * 所以必须包 try——这里抛错是预期的「还没就绪」,不是异常;
16
+ * ② 老线无 getOrigin 时退 `port`:宿主侧 `listenedPort` 未 listen 时读到 undefined,
17
+ * naive 模板串会产出 `http://127.0.0.1:undefined` 这种能发出去但永远错的 URL,
18
+ * 故仅当 `typeof port === 'number' && port > 0` 才用(这条退化路径把 TLS / 非 loopback
19
+ * 绑定当作 http+127.0.0.1 处理——那正是旧版本写死 8080 也没解决的面,不比旧行为更差);
20
+ * ③ cordis Proxy 面对未授权属性会直接抛,故与 webStartup 的 trustedHosts 读取同款两层 try。
21
+ *
22
+ * 一律「取不到就返回 undefined」:调用方据此落回写死的 8080,即逐字节等价旧行为。
23
+ */
24
+ /** 本模块用到的 webServer 服务面(结构类型,不依赖宿主包类型导出)。 */
25
+ export interface WebServerOriginFace {
26
+ /** 0.2.1-rc.5 起存在;未 listen / 绑定地址无 URL 形式时抛错。 */
27
+ getOrigin?: () => unknown;
28
+ /** OS 分配的实际监听口;未 listen 时为 undefined(老线只有这一档)。 */
29
+ port?: unknown;
30
+ }
31
+ /**
32
+ * 读取宿主监听 origin;任何取不到/抛错都返回 undefined(不抛,不影响请求)。
33
+ * @param ctx - 只用到 webServer 属性与可选的 ctx.get,故类型刻意收窄。
34
+ */
35
+ export declare function readWebServerOrigin(ctx: unknown): string | undefined;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * issue #20:宿主实际监听 origin 的降级读取(DrscClient 的第三档兜底)。
3
+ *
4
+ * 为何不写在 index.ts 里:它有两条取数路径 + 三个抛错入口,是需要被单测钉住的可判定
5
+ * 逻辑;埋在装配文件里就只能靠人肉真机核对,而 #20 的整条因果链正是「没人核对过」。
6
+ *
7
+ * 优先取 `getOrigin()`,再退 `port`——两者的可用性已逐线实测取证,别拿一条线当事实:
8
+ * ・0.2.0-rc.2 源码仓的 webServer **没有** getOrigin(),只有 `get port()` / `get host()`;
9
+ * ・0.2.1-rc.5 装机宿主(app.asar 内类体)已有 `getOrigin()`,返回
10
+ * `${protocol}//${bindUrlHost(host)}:${listenedPort}`——protocol 认 TLS、bind host 走
11
+ * URL 形式转换,这两个细节本插件自己算不出来,所以新线一律用它。
12
+ *
13
+ * 三条硬降级理由(缺一就不能发):
14
+ * ① `getOrigin()` 在未 listen / 绑定地址带 zone id 时**主动抛错**(不是返回 undefined),
15
+ * 所以必须包 try——这里抛错是预期的「还没就绪」,不是异常;
16
+ * ② 老线无 getOrigin 时退 `port`:宿主侧 `listenedPort` 未 listen 时读到 undefined,
17
+ * naive 模板串会产出 `http://127.0.0.1:undefined` 这种能发出去但永远错的 URL,
18
+ * 故仅当 `typeof port === 'number' && port > 0` 才用(这条退化路径把 TLS / 非 loopback
19
+ * 绑定当作 http+127.0.0.1 处理——那正是旧版本写死 8080 也没解决的面,不比旧行为更差);
20
+ * ③ cordis Proxy 面对未授权属性会直接抛,故与 webStartup 的 trustedHosts 读取同款两层 try。
21
+ *
22
+ * 一律「取不到就返回 undefined」:调用方据此落回写死的 8080,即逐字节等价旧行为。
23
+ */
24
+ /**
25
+ * 读取宿主监听 origin;任何取不到/抛错都返回 undefined(不抛,不影响请求)。
26
+ * @param ctx - 只用到 webServer 属性与可选的 ctx.get,故类型刻意收窄。
27
+ */
28
+ export function readWebServerOrigin(ctx) {
29
+ const svc = getWebServerFace(ctx);
30
+ if (svc === undefined)
31
+ return undefined;
32
+ // 首选宿主自算的 origin:protocol 与 bind host 形式都由它给定
33
+ try {
34
+ if (typeof svc.getOrigin === 'function') {
35
+ const origin = svc.getOrigin();
36
+ if (typeof origin === 'string' && origin.trim() !== '')
37
+ return origin.trim();
38
+ }
39
+ }
40
+ catch {
41
+ /* 未 listen / 绑定地址带 zone id:落到 port 一档,再落不动即交给调用方缺省 */
42
+ }
43
+ try {
44
+ const port = svc.port;
45
+ if (typeof port !== 'number' || !Number.isFinite(port) || port <= 0)
46
+ return undefined;
47
+ return `http://127.0.0.1:${port}`;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ /** webServer 实例读取(服务可能晚于本插件就绪;属性读抛则当作未就绪)。 */
54
+ function getWebServerFace(ctx) {
55
+ if (typeof ctx !== 'object' || ctx === null)
56
+ return undefined;
57
+ const carrier = ctx;
58
+ try {
59
+ const direct = carrier.webServer;
60
+ const svc = direct !== undefined
61
+ ? direct
62
+ : typeof carrier.get === 'function'
63
+ ? carrier.get('webServer')
64
+ : undefined;
65
+ return typeof svc === 'object' && svc !== null ? svc : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
@@ -723,6 +723,11 @@ usage) {
723
723
  contextDisposers.push(agent.ctx.systemPrompt.context({
724
724
  name: 'memory:profile',
725
725
  order: 510,
726
+ // issue #24:正文由蒸馏产出(L3 画像),库里可能存着宿主自己发的字面
727
+ // `{{TYPE_id}}` 脱敏 token。宿主对 interpolate 缺省按 true 处理,遇到不合
728
+ // `/^[a-z][a-z0-9_]*$/` 的变量组即在 prompt 组装期抛错、整轮报废——而本
729
+ // 文本从来不含宿主变量,插值只有风险没有收益,故显式关闭按原文注入。
730
+ interpolate: false,
726
731
  text: () => {
727
732
  const final = composeStableText(agent.id);
728
733
  const ledger = ledgerFor(agent.id);
package/dist/index.js CHANGED
@@ -53,6 +53,7 @@ import { ObsoleteNoticeStore } from './store/obsolete-notices.js';
53
53
  import { registerSlotsProjection } from './projection/slots.js';
54
54
  import { registerRecallProjection } from './projection/recall.js';
55
55
  import { DrscClient } from './clients/drsc.js';
56
+ import { readWebServerOrigin } from './clients/web-server-origin.js';
56
57
  import { errDetail, withFileLog } from './util/filelog.js';
57
58
  import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm.js';
58
59
  import { effectiveCfg } from './pipeline/runner.js';
@@ -84,6 +85,8 @@ function readStartupTrustedHosts(ctx) {
84
85
  }
85
86
  return undefined;
86
87
  }
88
+ // issue #20:DrscClient 的第三档兜底。取数逻辑已抽到 clients/web-server-origin.ts
89
+ // (两条取数路径 + 三个抛错入口,需要被单测钉住而不是靠人肉真机核对),此处只接线。
87
90
  /**
88
91
  * 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。
89
92
  * 0.1.5 起 connection.rpc.handle() 把 /rpc 路由登记到 webServer 时按**调用方
@@ -213,8 +216,11 @@ export async function apply(ctx, config) {
213
216
  // 轨2 技能结晶生产者(issue #15 的缺失环节):装配于下方(依赖 skillStore 就绪)。
214
217
  skills: undefined,
215
218
  // DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
216
- // 硬依赖无本地回退(l0 表退役);trustedHosts 惰性读取(webStartup 可能晚到)。
217
- drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
219
+ // 硬依赖无本地回退(l0 表退役);trustedHosts 与宿主 origin 均惰性读取(两者可能晚于本插件就绪)。
220
+ drsc: new DrscClient({
221
+ trustedHosts: () => readStartupTrustedHosts(ctx),
222
+ origin: () => readWebServerOrigin(ctx),
223
+ }),
218
224
  l0: new L0Store(db),
219
225
  l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
220
226
  // §D 第 3 路:图谱回链(声明于 stores 之前,embed 查询在下方接线)。
@@ -22,10 +22,19 @@
22
22
  */
23
23
  import type { Context } from '@deepseek-ai/cordis';
24
24
  import type { MemoryConfig } from './config.js';
25
- import type { MemoryLogger, MemoryRecord } from './types.js';
25
+ import type { MemoryLogger, MemoryRecord, RoomCount } from './types.js';
26
26
  export declare const ROOM_CANDIDATES_KEY = "roomCandidates";
27
27
  export declare const ROOM_REVIEW_KEY = "roomReview";
28
28
  export type RoomReviewState = 'pending' | 'confirmed' | 'skipped';
29
+ /**
30
+ * `listRooms()` 结果里的 slug→计数索引(单点定义,供四个消费方共用)。
31
+ *
32
+ * 为何需要它(issue #27):同一句「注册表条目的真实计数」在本仓写了四处,其中三处
33
+ * 已经是对的形态(`stats.ts` 的 rooms-get / rooms-export、`tools` 的 room-admin list),
34
+ * 只有候选词表这一处把算好的计数**写死成 0**。计数一旦有本地定义就有分叉位,
35
+ * 所以取数口径收到这里,各调用点只剩「查不到时给多少」这一个本地决策。
36
+ */
37
+ export declare function roomCountsBySlug(rooms: readonly RoomCount[]): Map<string, number>;
29
38
  /** 复查与候选操作对 L1 的最小依赖面(便于测试注入)。 */
30
39
  export interface RoomReviewStore {
31
40
  list(opts: {
@@ -2,6 +2,17 @@ import { isRoomSlug } from './metadata-validators.js';
2
2
  import { callLLM, parseJsonLogged } from './llm.js';
3
3
  export const ROOM_CANDIDATES_KEY = 'roomCandidates';
4
4
  export const ROOM_REVIEW_KEY = 'roomReview';
5
+ /**
6
+ * `listRooms()` 结果里的 slug→计数索引(单点定义,供四个消费方共用)。
7
+ *
8
+ * 为何需要它(issue #27):同一句「注册表条目的真实计数」在本仓写了四处,其中三处
9
+ * 已经是对的形态(`stats.ts` 的 rooms-get / rooms-export、`tools` 的 room-admin list),
10
+ * 只有候选词表这一处把算好的计数**写死成 0**。计数一旦有本地定义就有分叉位,
11
+ * 所以取数口径收到这里,各调用点只剩「查不到时给多少」这一个本地决策。
12
+ */
13
+ export function roomCountsBySlug(rooms) {
14
+ return new Map(rooms.map((r) => [r.room, r.count]));
15
+ }
5
16
  /** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isRoomSlug 校验(两级制);目录内优先,≤3)。 */
6
17
  export function parseRoomCandidateItems(parsed, ids, roomCatalog) {
7
18
  const idSet = new Set(ids);
@@ -107,8 +118,15 @@ export async function annotateOrphanCandidates(ctx, cfg, io, limit = 40, overrid
107
118
  const grown = io.l1.listRooms();
108
119
  const regActive = io.registry?.listActive() ?? [];
109
120
  const regSet = new Set(regActive.map((e) => e.slug));
121
+ // issue #27:注册条目从 grown 回查真计数,而不是写死 0。`listRooms()` 的 SQL 是按
122
+ // metadata.tags 展开全部记录聚合的,注册过的 slug 只认真挂了记录就必然出现在这里
123
+ // 且带正确计数——所以那个 0 不是「信息不可得」的降级,是把已算好的数据丢掉再补个假值。
124
+ // 后果是方向性的:词表里只有未注册项显示真数,归类器收到的唯一规模证据恰好与
125
+ // 「优先挂靠现有词表」这条规则反向——按推荐用法先 pre-register 的词表反而全变空壳。
126
+ // `?? 0` 保留原语义:只有「注册了但库里确实零绑定」的新 Room 才显示 0。
127
+ const grownCounts = roomCountsBySlug(grown);
110
128
  const merged = [
111
- ...regActive.map((e) => ({ room: e.slug, count: 0, label: e.label, description: e.description })),
129
+ ...regActive.map((e) => ({ room: e.slug, count: grownCounts.get(e.slug) ?? 0, label: e.label, description: e.description })),
112
130
  ...grown.filter((r) => !regSet.has(r.room)).map((r) => ({ room: r.room, count: r.count })),
113
131
  ].slice(0, 120);
114
132
  const annotate = overrides.annotator ??
package/dist/stats.js CHANGED
@@ -19,7 +19,7 @@ import { typeCatalogEntries } from './memory-types.js';
19
19
  import { effectiveCfg } from './pipeline/runner.js';
20
20
  import { emptyRecallStats } from './hooks/recall.js';
21
21
  import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
22
- import { mergeRoom, renameRoom } from './room-review.js';
22
+ import { mergeRoom, renameRoom, roomCountsBySlug } from './room-review.js';
23
23
  import { scanSources } from './memport/scan.js';
24
24
  import { projectDistillChain, validateDistillChain } from './settings.js';
25
25
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
@@ -1149,7 +1149,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1149
1149
  };
1150
1150
  if (kind === 'rooms') {
1151
1151
  const registry = roomRegistry?.list() ?? [];
1152
- const counts = new Map(stores.l1.listRooms().map((r) => [r.room, r.count]));
1152
+ const counts = roomCountsBySlug(stores.l1.listRooms());
1153
1153
  const lines = ['slug,count,source,label,description,status,aliases'];
1154
1154
  let n = 0;
1155
1155
  const seen = new Set();
@@ -23,7 +23,7 @@ import { isRetired, readSupersedeMarker, stripSupersedeMarker } from '../store/s
23
23
  import { listConflictPairs, renderConflictResolution, renderConflicts, resolveConflictPair } from '../conflict-service.js';
24
24
  import { WING_CATALOG, WING_FALLBACK, normPersistence, normScope, resolveRecordScope } from '../types.js';
25
25
  import { scopeFilterOf, workspaceIdOf } from '../workspace.js';
26
- import { annotateOrphanCandidates, confirmReview, mergeRoom, nextReview, pendingReviewCount, renameRoom, skipReview, } from '../room-review.js';
26
+ import { annotateOrphanCandidates, confirmReview, mergeRoom, nextReview, pendingReviewCount, roomCountsBySlug, renameRoom, skipReview, } from '../room-review.js';
27
27
  import { applyGovernanceWeights } from '../store/governance.js';
28
28
  import { resolveRepoScope } from '../repo-scope.js';
29
29
  import { GRAPH_STATUS_LABELS } from '../prompts/graph-projection.js';
@@ -386,8 +386,10 @@ graphWrite) {
386
386
  return { notice: 'Room 注册表未装配(需要插件重载以初始化)。' };
387
387
  const registry = roomRegistry.list();
388
388
  const counts = stores.l1.listRooms();
389
- const countOf = (slug) => counts.find((r) => r.room === slug)?.count ?? 0;
390
- const regLines = registry.map((e) => `- ${e.slug}${e.label ? `(${e.label})` : ''} [${e.source}/${e.status}] 现有 ${countOf(e.slug)} 条${e.description ? ` — ${e.description}` : ''}`);
389
+ // 取数口径单点(`roomCountsBySlug` 注释说明了为何收它):本行原先对每个注册条目
390
+ // 线性 find,上限 200 条注册表 × N 个 Room——语义相同、形状不同就是分叉位。
391
+ const countOf = roomCountsBySlug(counts);
392
+ const regLines = registry.map((e) => `- ${e.slug}${e.label ? `(${e.label})` : ''} [${e.source}/${e.status}] 现有 ${countOf.get(e.slug) ?? 0} 条${e.description ? ` — ${e.description}` : ''}`);
391
393
  const grownOnly = counts.filter((r) => !registry.some((e) => e.slug === r.room));
392
394
  return {
393
395
  notice: `注册表 ${registry.length} 条(上限 200),自生长未注册 ${grownOnly.length} 个。\n` +
@@ -1666,7 +1668,7 @@ graphWrite) {
1666
1668
  loser_id: { type: 'string' },
1667
1669
  loser_content: { type: 'string', description: '同上' },
1668
1670
  created_at: { type: 'string' },
1669
- // 以下 10 项与 conflict-service.listConflictPairs 视图逐一对齐(task 三轴/R1/Phase3):
1671
+ // 以下 12 项与 conflict-service.listConflictPairs 视图逐一对齐(task 三轴/R1/Phase3):
1670
1672
  // execute 直接返回该视图,additionalProperties:false 下少声明任何一个,
1671
1673
  // 宿主无损校验即拒("not a declared property"),整工具读不出——schema 必须跟视图同步。
1672
1674
  winner_valid_from_ms: { oneOf: [{ type: 'number' }, { type: 'null' }] },
@@ -1679,6 +1681,12 @@ graphWrite) {
1679
1681
  defer_count: { type: 'number', description: '复看次数(defer 上限 3)' },
1680
1682
  conflict_type: { type: 'string', description: '冲突类型(默认 hard)' },
1681
1683
  claim_key: { type: 'string', description: '同主题多对冲突的归并键(空串=未分组)' },
1684
+ // issue #23:Phase 3 arm-2/arm-3 给视图加了两笔成熟度旁注
1685
+ // (conflict-service.ts 的 maturityOf),声明面没跟着走 → `additionalProperties: false`
1686
+ // 下宿主无损校验逐条拒结,整工具读不出。`computeMaturity` 恒返三档、
1687
+ // 无 undefined 无第四值,故用 enum 而不是 free string。
1688
+ winner_maturity: { type: 'string', enum: ['low', 'mid', 'high'], description: '胜方成熟度(读时派生,不参与自动裁决)' },
1689
+ loser_maturity: { type: 'string', enum: ['low', 'mid', 'high'], description: '同上,败方' },
1682
1690
  },
1683
1691
  additionalProperties: false,
1684
1692
  },
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-prime-memory",
3
3
  "name": "dsh-prime-memory",
4
- "version": "0.26.16",
4
+ "version": "0.26.18",
5
5
  "description": {
6
6
  "en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
7
7
  "zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
@@ -19,4 +19,4 @@
19
19
  "host": "dist/index.js",
20
20
  "client": "dist/client.js"
21
21
  }
22
- }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.26.16",
3
+ "version": "0.26.18",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",
@@ -168,4 +168,4 @@
168
168
  "typescript-eslint": "^8.69.0",
169
169
  "vitest": "^4.1.11"
170
170
  }
171
- }
171
+ }