dsh-layered-memory 0.8.8 → 0.8.10

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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 记忆占用流水存储:sessionId → OccupancyLedger 的持久化映射。
3
+ *
4
+ * 动机(2026-08-27 票07):占用账本是进程内存态,dsh 重启后历史会话"窗口里明明
5
+ * 有记忆、账面却为零",指示器对重启前注入的存量失明。本存储让账目跨进程复生:
6
+ * - 每次账本迁移后写穿(recall-dedupe 同款:串行化原子写 + 失败降级内存态);
7
+ * - 查询热路径仍是同步内存读——文件只在迁移时刻与启动载入时触碰;
8
+ * - stock 归零的条目不落盘(compaction 复位即天然删除);
9
+ * - agent 销毁不删记录(持久化语义与召回去重一致:会话恢复后账目继续);
10
+ * - 90 天未更新过期清理 + 会话条目上限(session-modes/dedupe 同款量级)。
11
+ */
12
+ import * as path from 'node:path';
13
+ import { errDetail } from '../util/filelog.js';
14
+ import { atomicWriteJson, ensureDir, readJsonIfExists } from './io.js';
15
+ /** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
16
+ export const OCCUPANCY_SESSION_CAP = 200;
17
+ /** 条目过期清理(90 天未更新即丢弃,与 session-modes/dedupe 同款量级)。 */
18
+ const PRUNE_MS = 90 * 24 * 3600_000;
19
+ export class OccupancyStore {
20
+ logger;
21
+ file;
22
+ entries = new Map();
23
+ persistFailed = false;
24
+ /** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
25
+ writeChain;
26
+ constructor(dataDir, logger) {
27
+ this.logger = logger;
28
+ this.file = path.join(dataDir, 'occupancy.json');
29
+ this.writeChain = this.init();
30
+ }
31
+ /** 载入持久化账目(合并进内存——构造与载入之间发生的 save 不丢);失败降级内存态。 */
32
+ async init() {
33
+ const data = await readJsonIfExists(this.file);
34
+ if (!data?.sessions || typeof data.sessions !== 'object')
35
+ return;
36
+ const now = Date.now();
37
+ let count = 0;
38
+ for (const [sid, entry] of Object.entries(data.sessions)) {
39
+ if (typeof entry?.stockTokens !== 'number')
40
+ continue;
41
+ if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
42
+ continue;
43
+ const existing = this.entries.get(sid);
44
+ if (existing) {
45
+ if (entry.updatedAt > existing.updatedAt)
46
+ this.entries.set(sid, { ...entry });
47
+ }
48
+ else {
49
+ this.entries.set(sid, { ...entry });
50
+ }
51
+ count++;
52
+ }
53
+ if (count > 0)
54
+ this.logger?.info(`[memory] 记忆占用账目载入 ${count} 个会话`);
55
+ }
56
+ /**
57
+ * 该会话的持久化账目(热路径同步读;从未注入/已复位返回 null)。
58
+ * 返回**浅拷贝**——调用方(ledgerFor 复生)会在其上做迁移;若交出内部引用,
59
+ * 原地修改会让 save() 的 prev/ledger 恒等比较误判“数值未变”而跳过写穿(实测踩坑)。
60
+ */
61
+ load(sessionId) {
62
+ const e = this.entries.get(sessionId);
63
+ return e ? { ...e } : null;
64
+ }
65
+ /**
66
+ * 迁移后写穿。数值未变只刷新内存时间戳不落盘(profile 稳定区每次请求组装都
67
+ * 触发迁移,但内容不变时不应产生文件写);stock 归零即删除条目。
68
+ */
69
+ save(sessionId, ledger) {
70
+ const prev = this.entries.get(sessionId);
71
+ const sameNumbers = prev !== undefined &&
72
+ prev.stockTokens === ledger.stockTokens &&
73
+ prev.recallTokens === ledger.recallTokens &&
74
+ prev.profileTokens === ledger.profileTokens &&
75
+ prev.lastInjectTokens === ledger.lastInjectTokens;
76
+ if (sameNumbers) {
77
+ prev.updatedAt = ledger.updatedAt;
78
+ return;
79
+ }
80
+ if (ledger.stockTokens <= 0) {
81
+ if (prev === undefined)
82
+ return;
83
+ this.entries.delete(sessionId);
84
+ }
85
+ else {
86
+ this.entries.set(sessionId, { ...ledger });
87
+ }
88
+ this.writeChain = this.writeChain.then(() => this.persist());
89
+ }
90
+ /** 等待在途持久化写完成(测试/停机用)。 */
91
+ flush() {
92
+ return this.writeChain;
93
+ }
94
+ async persist() {
95
+ try {
96
+ await ensureDir(path.dirname(this.file));
97
+ await atomicWriteJson(this.file, this.serialize());
98
+ this.persistFailed = false;
99
+ }
100
+ catch (err) {
101
+ if (!this.persistFailed) {
102
+ this.persistFailed = true;
103
+ this.logger?.warn(`[memory] 记忆占用持久化失败(降级内存态): ${errDetail(err)}`);
104
+ }
105
+ }
106
+ }
107
+ serialize() {
108
+ const now = Date.now();
109
+ for (const [sid, e] of this.entries) {
110
+ if (now - e.updatedAt > PRUNE_MS && e.updatedAt > 0)
111
+ this.entries.delete(sid);
112
+ }
113
+ while (this.entries.size > OCCUPANCY_SESSION_CAP) {
114
+ let oldest;
115
+ let oldestAt = Infinity;
116
+ for (const [sid, e] of this.entries) {
117
+ if (e.updatedAt > 0 && e.updatedAt < oldestAt) {
118
+ oldest = sid;
119
+ oldestAt = e.updatedAt;
120
+ }
121
+ }
122
+ if (oldest === undefined)
123
+ break;
124
+ this.entries.delete(oldest);
125
+ }
126
+ const sessions = {};
127
+ for (const [sid, e] of this.entries) {
128
+ if (e.stockTokens <= 0)
129
+ continue; // 归零条目不落盘
130
+ sessions[sid] = { ...e };
131
+ }
132
+ return { version: 1, sessions };
133
+ }
134
+ }
@@ -17,6 +17,13 @@ export declare class SessionModeStore {
17
17
  get default(): MemoryMode;
18
18
  /** 同步读取:未设置过的会话返回默认档。 */
19
19
  get(sessionId: string): MemoryMode;
20
+ /** 会话级注入覆盖原始值(#38):undefined = 未覆盖,跟随全局。 */
21
+ getRecall(sessionId: string): boolean | undefined;
22
+ /** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
23
+ * 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
24
+ resolvedRecall(sessionId: string, globalRecall: boolean): boolean;
25
+ /** 设置会话级注入覆盖(#38;undefined = 清除覆盖跟随全局。写穿持久化)。 */
26
+ setRecall(sessionId: string, recall: boolean | undefined): void;
20
27
  /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
21
28
  setModeChangeHandler(cb: (sessionId: string, oldMode: MemoryMode, newMode: MemoryMode) => void): void;
22
29
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
@@ -42,7 +42,12 @@ export class SessionModeStore {
42
42
  continue;
43
43
  if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
44
44
  continue;
45
- this.entries.set(sid, { mode: entry.mode, updatedAt: entry.updatedAt ?? now });
45
+ this.entries.set(sid, {
46
+ mode: entry.mode,
47
+ // 非布尔视为损坏丢弃(= 跟随全局);旧文件无此键同款兼容
48
+ recall: typeof entry.recall === 'boolean' ? entry.recall : undefined,
49
+ updatedAt: entry.updatedAt ?? now,
50
+ });
46
51
  count++;
47
52
  }
48
53
  if (count > 0)
@@ -55,6 +60,25 @@ export class SessionModeStore {
55
60
  get(sessionId) {
56
61
  return this.entries.get(sessionId)?.mode ?? this.loaded;
57
62
  }
63
+ /** 会话级注入覆盖原始值(#38):undefined = 未覆盖,跟随全局。 */
64
+ getRecall(sessionId) {
65
+ return this.entries.get(sessionId)?.recall;
66
+ }
67
+ /** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
68
+ * 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
69
+ resolvedRecall(sessionId, globalRecall) {
70
+ return this.entries.get(sessionId)?.recall ?? globalRecall;
71
+ }
72
+ /** 设置会话级注入覆盖(#38;undefined = 清除覆盖跟随全局。写穿持久化)。 */
73
+ setRecall(sessionId, recall) {
74
+ const entry = this.entries.get(sessionId);
75
+ this.entries.set(sessionId, {
76
+ mode: entry?.mode ?? this.loaded,
77
+ recall,
78
+ updatedAt: Date.now(),
79
+ });
80
+ this.writeChain = this.writeChain.then(() => this.persist());
81
+ }
58
82
  /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
59
83
  setModeChangeHandler(cb) {
60
84
  this.onModeChange = cb;
@@ -62,7 +86,9 @@ export class SessionModeStore {
62
86
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
63
87
  set(sessionId, mode) {
64
88
  const old = this.get(sessionId);
65
- this.entries.set(sessionId, { mode, updatedAt: Date.now() });
89
+ // 已有注入覆盖跨切档保留(#38:档位与注入正交,切档不动覆盖)
90
+ const recall = this.entries.get(sessionId)?.recall;
91
+ this.entries.set(sessionId, { mode, recall, updatedAt: Date.now() });
66
92
  this.writeChain = this.writeChain.then(() => this.persist());
67
93
  if (old !== mode && this.onModeChange) {
68
94
  try {
@@ -4,9 +4,12 @@
4
4
  * 会话档位联动:execute 的 exec.agent 即发起调用的 agent(agent.id === sessionId),
5
5
  * memory_search 按会话档位过滤族(auto 不过滤,纯档只查本族);off 档下三工具统一
6
6
  * 返回提示(本会话已对记忆系统隐身)。conversation_search 检索范围保持全库。
7
+ * 只写会话(#38:注入覆盖=关)同款拒读,notice 区分文案——写入走捕获钩子不经工具,
8
+ * 拒读不影响"只写"语义。
7
9
  */
8
10
  import type { Context } from '@deepseek-ai/cordis';
9
11
  import type { MemoryConfig } from '../config.js';
12
+ import type { LiveSettingsHandle } from '../settings.js';
10
13
  import type { L0Store } from '../store/l0.js';
11
14
  import type { L1Store } from '../store/l1.js';
12
15
  import type { PersonaStore } from '../store/persona.js';
@@ -18,4 +21,4 @@ export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, sto
18
21
  l1: L1Store;
19
22
  scenes: Record<MemoryFamily, SceneStore>;
20
23
  persona: Record<MemoryFamily, PersonaStore>;
21
- }, logger: MemoryLogger, modes: SessionModeStore): void;
24
+ }, logger: MemoryLogger, modes: SessionModeStore, live: LiveSettingsHandle): void;
@@ -1,10 +1,12 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  const OFF_NOTICE = '本会话的记忆档位为"关闭":该会话对记忆系统完全隐身,不读取也不写入记忆。';
3
- export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
3
+ const WRITE_ONLY_NOTICE = '本会话为只写模式:记忆照常沉淀,但不读取。';
4
+ const GLOBAL_OFF_NOTICE = '记忆注入已全局停用:本会话不读取记忆(沉淀照常)。';
5
+ export function registerMemoryTools(ctx, cfg, stores, logger, modes, live) {
4
6
  if (!cfg.tools)
5
7
  return;
6
8
  /**
7
- * 调用会话的检索族(auto → undefined 不过滤;off → null 表示整体禁用)。
9
+ * 调用会话的检索族(auto → undefined 不过滤;off/只写 → null 表示整体禁用)。
8
10
  * fail-open:exec.agent 缺失(宿主调用路径未带 agent 标识)按全族检索放行——
9
11
  * 档位隔离依赖宿主正确传递 exec.agent.id,缺失只告警一次不拒绝工具调用。
10
12
  */
@@ -20,8 +22,24 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
20
22
  const mode = modes.get(agentId);
21
23
  if (mode === 'off')
22
24
  return null;
25
+ // 只写会话拒读(#38,T2 裁决):与注入同属读维度,不拒则"不注入"从工具路径漏风
26
+ if (!modes.resolvedRecall(agentId, live.get().recall))
27
+ return null;
23
28
  return mode === 'auto' ? undefined : mode;
24
29
  };
30
+ /** 拒读时的归因文案(familyOfCaller 判 null 后重查内存 Map,成本可忽略):
31
+ * off 完全隐身 / 会话只写覆盖 / 全局召回关——三种停用各说各话,不谎报只写。 */
32
+ const blockNoticeOf = (agentId) => {
33
+ if (agentId !== undefined) {
34
+ if (modes.get(agentId) === 'off')
35
+ return OFF_NOTICE;
36
+ if (modes.getRecall(agentId) === false)
37
+ return WRITE_ONLY_NOTICE;
38
+ if (!modes.resolvedRecall(agentId, live.get().recall))
39
+ return GLOBAL_OFF_NOTICE;
40
+ }
41
+ return OFF_NOTICE;
42
+ };
25
43
  // ── memory_search: L1 结构化记忆 ──
26
44
  ctx.tools.register(defineTool({
27
45
  name: 'memory_search',
@@ -59,7 +77,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
59
77
  execute: async (args, exec) => {
60
78
  const family = familyOfCaller(exec.agent?.id);
61
79
  if (family === null)
62
- return { items: [], notice: OFF_NOTICE };
80
+ return { items: [], notice: blockNoticeOf(exec.agent?.id) };
63
81
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
64
82
  const hits = await stores.l1.search(args.query, limit, { type: args.type || undefined, family: family ?? undefined });
65
83
  return {
@@ -107,7 +125,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
107
125
  },
108
126
  execute: async (args, exec) => {
109
127
  if (familyOfCaller(exec.agent?.id) === null)
110
- return { items: [], notice: OFF_NOTICE };
128
+ return { items: [], notice: blockNoticeOf(exec.agent?.id) };
111
129
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
112
130
  const records = await stores.l0.search(args.query, limit);
113
131
  return {
@@ -141,7 +159,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
141
159
  },
142
160
  execute: async (args, exec) => {
143
161
  if (familyOfCaller(exec.agent?.id) === null)
144
- return { content: OFF_NOTICE };
162
+ return { content: blockNoticeOf(exec.agent?.id) };
145
163
  const p = args.path.trim();
146
164
  let content;
147
165
  if (p === 'persona.md' || p === 'persona-chat.md' || p === 'persona' || p === 'persona-chat') {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 上下文占用账本:插件注入内容在当前会话上下文窗口中的启发式 token 存量。
3
+ *
4
+ * 本模块是全部占用数字的**唯一算术来源**(host 记账与 client 渲染共用,esbuild
5
+ * 将其内联进 client bundle),任何一侧不得另写算法。口径与官方
6
+ * @deepseek-ai/dsh-token-meter/estimate 的固定密度启发式同式:
7
+ *
8
+ * - text 块 = ceil(chars / 4) + 4(块开销);
9
+ * - 整条 user 消息(召回注入体的形态)再叠加 role overhead 4;
10
+ * - 系统提示里的稳定区**子片**计入时不加任何 overhead——那 +8 是官方对整段
11
+ * 消息/整体系统提示的结构成本,切片重复计会与官方总数对不上;
12
+ * - 字符数一律 JS `.length`(UTF-16 单元),与官方产出严格可比。
13
+ */
14
+ /** 固定文本密度:每 4 字符记 1 token(官方 CHARS_PER_TOKEN 同值)。 */
15
+ export declare const CHARS_PER_TOKEN = 4;
16
+ /** 召回注入体(单 text 块 user message)的启发式 token 数。 */
17
+ export declare function estimateInjectedMessageTokens(chars: number): number;
18
+ /** 稳定区子片的启发式 token 数(不加结构开销,见模块头注释)。 */
19
+ export declare function estimateStableSectionTokens(chars: number): number;
20
+ /**
21
+ * 单会话占用账本(权威账本只存在于 host 侧;client 仅消费展示)。
22
+ * stockTokens = recallTokens + profileTokens 恒等式由三个迁移函数共同维护。
23
+ */
24
+ export interface OccupancyLedger {
25
+ /** 当前估算仍在上下文窗口内的记忆存量(token)。 */
26
+ stockTokens: number;
27
+ /** 累计召回注入存留份额。 */
28
+ recallTokens: number;
29
+ /** 系统提示稳定区当前份额(OFF 使 text() 返回空时即时清零)。 */
30
+ profileTokens: number;
31
+ /** 最近一轮召回注入的增量(悬浮卡/面板"最近一次"语义)。 */
32
+ lastInjectTokens: number;
33
+ updatedAt: number;
34
+ }
35
+ export declare function emptyOccupancyLedger(now?: number): OccupancyLedger;
36
+ /** 召回注入成功后的入账(失败跳过的轮次不调用——账目零扰动)。 */
37
+ export declare function recordRecallInjection(l: OccupancyLedger, chars: number, now?: number): void;
38
+ /**
39
+ * 稳定区份额随每次实际组进的文本同步(含切回非 OFF 的重新计入):
40
+ * 增量记账保证重复调用不双算,清零后重设即可净额回补。
41
+ */
42
+ export declare function recordProfileShare(l: OccupancyLedger, chars: number, now?: number): void;
43
+ /** OFF 边界:稳定区随下一次请求组装物理离场,账目同边界清零(宁低勿高)。 */
44
+ export declare function clearProfileShare(l: OccupancyLedger, now?: number): void;
45
+ /**
46
+ * compaction 复位:v1 采用"清零重新累积"的低估近似——被压缩掉的注入以整数轮粒度
47
+ * 退出账本,幸存的摘要行不重复认领(已知限制,方向宁低勿高;事件级精确对齐留待 v2)。
48
+ */
49
+ export declare function resetForCompaction(l: OccupancyLedger, now?: number): void;
50
+ /**
51
+ * 官方上下文环周长:viewBox 14 内 r=5.5 圆(2π×5.5 ≈ 34.55751918948772),
52
+ * 与真机反推的官方 fill dasharray 分母逐字一致。
53
+ */
54
+ export declare const CONTEXT_METER_CIRCUMFERENCE = 34.55751918948772;
55
+ /**
56
+ * 光晕弧 stroke-dasharray:形状沿用官方 fill(len + 全周长 gap)。
57
+ * @param occupancyRatio 占窗口比 0..1(越界钳制)。
58
+ * @param minLen 最小可见弧长(单位=-viewBox 坐标;14px 渲染下 <1 单位即亚像素不可见,
59
+ * 真实占比常低至 0.6%≈0.2 单位——指示灯语义需要"存在即可见",精确数字归面板)。
60
+ */
61
+ export declare function haloDashArray(occupancyRatio: number, circumference?: number, minLen?: number): string;
62
+ /** 官方环触发按钮的结构签名(locale 无关定位依据;见组件设计 C1 锚点节)。 */
63
+ export interface ContextMeterAnchorSignature {
64
+ ariaHasPopup?: string | null;
65
+ viewBox?: string | null;
66
+ circleRadii?: ReadonlyArray<number>;
67
+ }
68
+ export declare function isContextMeterAnchor(sig: ContextMeterAnchorSignature): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 上下文占用账本:插件注入内容在当前会话上下文窗口中的启发式 token 存量。
3
+ *
4
+ * 本模块是全部占用数字的**唯一算术来源**(host 记账与 client 渲染共用,esbuild
5
+ * 将其内联进 client bundle),任何一侧不得另写算法。口径与官方
6
+ * @deepseek-ai/dsh-token-meter/estimate 的固定密度启发式同式:
7
+ *
8
+ * - text 块 = ceil(chars / 4) + 4(块开销);
9
+ * - 整条 user 消息(召回注入体的形态)再叠加 role overhead 4;
10
+ * - 系统提示里的稳定区**子片**计入时不加任何 overhead——那 +8 是官方对整段
11
+ * 消息/整体系统提示的结构成本,切片重复计会与官方总数对不上;
12
+ * - 字符数一律 JS `.length`(UTF-16 单元),与官方产出严格可比。
13
+ */
14
+ /** 固定文本密度:每 4 字符记 1 token(官方 CHARS_PER_TOKEN 同值)。 */
15
+ export const CHARS_PER_TOKEN = 4;
16
+ const BLOCK_OVERHEAD = 4;
17
+ const ROLE_OVERHEAD = 4;
18
+ /** 召回注入体(单 text 块 user message)的启发式 token 数。 */
19
+ export function estimateInjectedMessageTokens(chars) {
20
+ return Math.ceil(chars / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + ROLE_OVERHEAD;
21
+ }
22
+ /** 稳定区子片的启发式 token 数(不加结构开销,见模块头注释)。 */
23
+ export function estimateStableSectionTokens(chars) {
24
+ return Math.ceil(chars / CHARS_PER_TOKEN);
25
+ }
26
+ export function emptyOccupancyLedger(now = Date.now()) {
27
+ return { stockTokens: 0, recallTokens: 0, profileTokens: 0, lastInjectTokens: 0, updatedAt: now };
28
+ }
29
+ /** 召回注入成功后的入账(失败跳过的轮次不调用——账目零扰动)。 */
30
+ export function recordRecallInjection(l, chars, now = Date.now()) {
31
+ const tokens = estimateInjectedMessageTokens(chars);
32
+ l.recallTokens += tokens;
33
+ l.stockTokens += tokens;
34
+ l.lastInjectTokens = tokens;
35
+ l.updatedAt = now;
36
+ }
37
+ /**
38
+ * 稳定区份额随每次实际组进的文本同步(含切回非 OFF 的重新计入):
39
+ * 增量记账保证重复调用不双算,清零后重设即可净额回补。
40
+ */
41
+ export function recordProfileShare(l, chars, now = Date.now()) {
42
+ const tokens = estimateStableSectionTokens(chars);
43
+ l.stockTokens += tokens - l.profileTokens;
44
+ l.profileTokens = tokens;
45
+ l.updatedAt = now;
46
+ }
47
+ /** OFF 边界:稳定区随下一次请求组装物理离场,账目同边界清零(宁低勿高)。 */
48
+ export function clearProfileShare(l, now = Date.now()) {
49
+ l.stockTokens -= l.profileTokens;
50
+ l.profileTokens = 0;
51
+ l.updatedAt = now;
52
+ }
53
+ /**
54
+ * compaction 复位:v1 采用"清零重新累积"的低估近似——被压缩掉的注入以整数轮粒度
55
+ * 退出账本,幸存的摘要行不重复认领(已知限制,方向宁低勿高;事件级精确对齐留待 v2)。
56
+ */
57
+ export function resetForCompaction(l, now = Date.now()) {
58
+ l.stockTokens = 0;
59
+ l.recallTokens = 0;
60
+ l.profileTokens = 0;
61
+ l.lastInjectTokens = 0;
62
+ l.updatedAt = now;
63
+ }
64
+ /* ── 渲染侧纯函数(client 寄生组件用;宿主 smoke 一并断言,数字唯一来源在此) ── */
65
+ /**
66
+ * 官方上下文环周长:viewBox 14 内 r=5.5 圆(2π×5.5 ≈ 34.55751918948772),
67
+ * 与真机反推的官方 fill dasharray 分母逐字一致。
68
+ */
69
+ export const CONTEXT_METER_CIRCUMFERENCE = 34.55751918948772;
70
+ /**
71
+ * 光晕弧 stroke-dasharray:形状沿用官方 fill(len + 全周长 gap)。
72
+ * @param occupancyRatio 占窗口比 0..1(越界钳制)。
73
+ * @param minLen 最小可见弧长(单位=-viewBox 坐标;14px 渲染下 <1 单位即亚像素不可见,
74
+ * 真实占比常低至 0.6%≈0.2 单位——指示灯语义需要"存在即可见",精确数字归面板)。
75
+ */
76
+ export function haloDashArray(occupancyRatio, circumference = CONTEXT_METER_CIRCUMFERENCE, minLen = 0) {
77
+ const clamped = Number.isFinite(occupancyRatio) ? Math.min(1, Math.max(0, occupancyRatio)) : 0;
78
+ const len = Math.max(clamped * circumference, clamped > 0 ? minLen : 0);
79
+ return `${len} ${circumference}`;
80
+ }
81
+ /** 半径比对容差:吞掉渲染库对属性值的无害浮点噪声。 */
82
+ const RADIUS_EPSILON = 1e-6;
83
+ export function isContextMeterAnchor(sig) {
84
+ if (sig.ariaHasPopup !== 'dialog')
85
+ return false;
86
+ if (sig.viewBox !== '0 0 14 14')
87
+ return false;
88
+ const radii = sig.circleRadii;
89
+ if (!Array.isArray(radii) || radii.length !== 2)
90
+ return false;
91
+ return radii.every((r) => Math.abs(r - 5.5) < RADIUS_EPSILON);
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-layered-memory",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "L0~L3 分层蒸馏记忆插件 for DeepSeek Harness:自动捕获对话(L0)、抽取原子记忆(L1)、整合场景块(L2)、蒸馏核心画像/团队方法论(L3),并在模型步骤前自动召回注入。移植自 MemoryCore (TencentDB Agent Memory) 的管线设计。",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",