dsh-layered-memory 0.8.8 → 0.8.9
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/README.en.md +21 -1
- package/README.md +25 -1
- package/assets/changelog/0.8.9/01-panel.png +0 -0
- package/assets/changelog/0.8.9/02-halo.png +0 -0
- package/assets/changelog/0.8.9/03-layer-segmented-panel.png +0 -0
- package/assets/changelog/0.8.9/04-layer-l1-panel.png +0 -0
- package/dist/client.js +1275 -749
- package/dist/config.d.ts +237 -1
- package/dist/config.js +19 -0
- package/dist/contract.d.ts +37 -0
- package/dist/hooks/recall.d.ts +11 -0
- package/dist/hooks/recall.js +143 -20
- package/dist/index.d.ts +228 -0
- package/dist/index.js +3 -0
- package/dist/llm.d.ts +34 -8
- package/dist/llm.js +93 -10
- package/dist/pipeline/runner.js +15 -1
- package/dist/settings.d.ts +9 -2
- package/dist/settings.js +51 -25
- package/dist/stats.d.ts +7 -0
- package/dist/stats.js +71 -3
- package/dist/store/occupancy.d.ts +30 -0
- package/dist/store/occupancy.js +134 -0
- package/dist/util/context-occupancy.d.ts +68 -0
- package/dist/util/context-occupancy.js +92 -0
- package/package.json +1 -1
package/dist/llm.js
CHANGED
|
@@ -22,11 +22,13 @@ export const LAYER_DEFAULT_BUDGETS = {
|
|
|
22
22
|
/**
|
|
23
23
|
* 解析某蒸馏层的生效输出预算:运行时覆盖(cfg.llm.budgets,由 effectiveCfg 从
|
|
24
24
|
* 设置页 distillBudgets 注入,0/缺省 = 跟随)→ 内置默认 → 思考档放大
|
|
25
|
-
* (high/xhigh/max ×4,reasoning
|
|
25
|
+
* (high/xhigh/max ×4,reasoning 计入输出预算的历史事故防线)。放大触发档位
|
|
26
|
+
* 跟层走(#34 D8):层链头档位候选 > 全局主路由档位候选(primaryEffort > 静态全局)。
|
|
26
27
|
*/
|
|
27
28
|
export function resolveLayerTokens(cfg, layer) {
|
|
28
29
|
const override = cfg.llm.budgets?.[layer];
|
|
29
|
-
|
|
30
|
+
const key = layer === 'l2' ? 'l2' : layer === 'l3' ? 'l3' : 'l1';
|
|
31
|
+
return layerMaxTokens(override && override > 0 ? override : LAYER_DEFAULT_BUDGETS[layer], layerEffortTrigger(cfg, key));
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* 高思考档集合(输出预算 ×4 的档位):阶段侧 layerMaxTokens 与 callLLM 的
|
|
@@ -74,6 +76,60 @@ export function buildRouteChain(primary, fallbacks, globalEffort) {
|
|
|
74
76
|
}
|
|
75
77
|
return routes;
|
|
76
78
|
}
|
|
79
|
+
// ── 按层独立路由(#34 / ADR-0005):层内三级 运行时层链 > 静态层链 > 全局解析 ──
|
|
80
|
+
// 层链非空即完整替换该层解析(该层主路由与回退都归层链管,全局链对该层不参与);
|
|
81
|
+
// 档位是全局偏好轴:层链空档位条目/头行仍回退全局档位(primaryEffort 之后的
|
|
82
|
+
// cfg.llm.reasoningEffort 语义照旧)。pin 只废运行时侧(effectiveCfg 不注入),
|
|
83
|
+
// 静态层链天然穿透——与回退链"静态配置不构成 pin 绕过"先例同源。
|
|
84
|
+
/** DistillLayer(调用点四键)→ 路由层键(三键):l1-extract/l1-dedup 同属 l1。 */
|
|
85
|
+
export function layerKeyFor(layer) {
|
|
86
|
+
return layer === 'l2' ? 'l2' : layer === 'l3' ? 'l3' : 'l1';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 取某层的生效层链(运行时优先)。头行残缺(provider/model 缺失——手写 YAML 错误
|
|
90
|
+
* 或防御性解析后的空链头)视为该层未配置、回退全局解析:路由配置错误不致该层
|
|
91
|
+
* 蒸馏失产,与回退链"条目缺失剔除"同一防御姿态。
|
|
92
|
+
*/
|
|
93
|
+
function layerChainOf(cfg, key) {
|
|
94
|
+
const rt = cfg.llm.layerChainsRuntime?.[key];
|
|
95
|
+
if (rt?.length && rt[0].provider && rt[0].model)
|
|
96
|
+
return rt;
|
|
97
|
+
const st = cfg.llm.layerRoutes?.[key];
|
|
98
|
+
if (st?.length && st[0].provider && st[0].model)
|
|
99
|
+
return st;
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/** 该层的预算放大触发档位(D8):层链头档位候选 > 全局主路由档位候选(primaryEffort > 静态全局)。 */
|
|
103
|
+
export function layerEffortTrigger(cfg, key) {
|
|
104
|
+
const chain = layerChainOf(cfg, key);
|
|
105
|
+
return chain
|
|
106
|
+
? chain[0].reasoningEffort || cfg.llm.primaryEffort || cfg.llm.reasoningEffort
|
|
107
|
+
: cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 解析某次蒸馏调用的实际路由链(callLLM 入口):有层标签且该层配了层链 → 层链
|
|
111
|
+
* 完整替换(buildRouteChain 复用:头行在前、条目去重、档位三级候选);否则现行
|
|
112
|
+
* 全局解析(主路由既有优先级 + fallbacks,语义一个比特不动)。layer 缺省
|
|
113
|
+
* (bench/测试缝)= 全局解析。
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveLayerRoutes(ctx, cfg, layer) {
|
|
116
|
+
const key = layer ? layerKeyFor(layer) : undefined;
|
|
117
|
+
const lr = key ? layerChainOrNull(cfg, key) : null;
|
|
118
|
+
if (lr)
|
|
119
|
+
return lr;
|
|
120
|
+
const primary = await resolveModelRoute(ctx, cfg);
|
|
121
|
+
return buildRouteChain(
|
|
122
|
+
// 主路由显式档位来自运行时统一链(primaryEffort,'' = 跟随全局静态)
|
|
123
|
+
{ provider: primary.provider, model: primary.model, effort: cfg.llm.primaryEffort || '' }, cfg.llm.fallbacks, cfg.llm.reasoningEffort);
|
|
124
|
+
}
|
|
125
|
+
/** 层链解析的同步半边(llm-providers 视图与 resolveLayerRoutes 共用一条真值路径):
|
|
126
|
+
* 该层配了有效层链 → 完整链;null = 该层跟随全局解析。 */
|
|
127
|
+
export function layerChainOrNull(cfg, key) {
|
|
128
|
+
const chain = layerChainOf(cfg, key);
|
|
129
|
+
if (!chain)
|
|
130
|
+
return null;
|
|
131
|
+
return buildRouteChain({ provider: chain[0].provider, model: chain[0].model, effort: chain[0].reasoningEffort || '' }, chain.slice(1), cfg.llm.reasoningEffort);
|
|
132
|
+
}
|
|
77
133
|
/** 单路由持续失败的一次性告警去重表(effortWarned 同款;拓扑变化随能力缓存一起失效)。 */
|
|
78
134
|
const routeDeadWarned = new Set();
|
|
79
135
|
function warnRouteDeadOnce(route, logger) {
|
|
@@ -89,6 +145,7 @@ const effortCache = new Map();
|
|
|
89
145
|
/** 清空能力缓存(llm/adapters-updated 时调用:供应商增删/改配置后重新探询)。 */
|
|
90
146
|
export function invalidateEffortCache() {
|
|
91
147
|
effortCache.clear();
|
|
148
|
+
contextWindowCache.clear();
|
|
92
149
|
effortWarned.clear();
|
|
93
150
|
routeDeadWarned.clear();
|
|
94
151
|
}
|
|
@@ -116,6 +173,30 @@ export async function resolveModelEfforts(ctx, provider, model) {
|
|
|
116
173
|
return null; // 不缓存失败:路由尚未注册等瞬时态,下次调用重试
|
|
117
174
|
}
|
|
118
175
|
}
|
|
176
|
+
/** (provider, model) → 上下文窗口 token 数;advisory,未声明/失败为 null。 */
|
|
177
|
+
const contextWindowCache = new Map();
|
|
178
|
+
/**
|
|
179
|
+
* 探询某模型的上下文窗口容量(adapter 声明的 provider-owned capacity)。
|
|
180
|
+
* 与 effortCache 同源同失效策略(invalidateEffortCache 一并清空);
|
|
181
|
+
* 仅用于占用指示器的分母展示——分母必须与官方环同源(模型声明值),禁止 client 自估。
|
|
182
|
+
*/
|
|
183
|
+
export async function resolveModelContextWindow(ctx, provider, model) {
|
|
184
|
+
const key = `${provider}::${model}`;
|
|
185
|
+
if (contextWindowCache.has(key))
|
|
186
|
+
return contextWindowCache.get(key) ?? null;
|
|
187
|
+
try {
|
|
188
|
+
if (typeof ctx.llm?.resolveModelInfo !== 'function')
|
|
189
|
+
return null;
|
|
190
|
+
const info = await ctx.llm.resolveModelInfo(provider, model);
|
|
191
|
+
const win = info.context?.contextWindow;
|
|
192
|
+
const val = typeof win === 'number' && Number.isFinite(win) && win > 0 ? Math.floor(win) : null;
|
|
193
|
+
contextWindowCache.set(key, val);
|
|
194
|
+
return val;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null; // 不缓存失败:下次调用重试
|
|
198
|
+
}
|
|
199
|
+
}
|
|
119
200
|
/** 纯决策:配置档位 + 模型能力 → 实际发送值(callLLM 与 settings-get 共用)。 */
|
|
120
201
|
export function decideSendableEffort(cap, cfgEffort) {
|
|
121
202
|
if (!cap)
|
|
@@ -165,10 +246,8 @@ export async function planDistillEffort(ctx, provider, model, cfgEffort, logger)
|
|
|
165
246
|
* 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
|
|
166
247
|
*/
|
|
167
248
|
export async function callLLM(ctx, cfg, opts) {
|
|
168
|
-
|
|
169
|
-
const routes =
|
|
170
|
-
// 主路由显式档位来自运行时统一链(primaryEffort,'' = 跟随全局静态)
|
|
171
|
-
{ provider: primary.provider, model: primary.model, effort: cfg.llm.primaryEffort || '' }, cfg.llm.fallbacks, cfg.llm.reasoningEffort);
|
|
249
|
+
// 按层路由(#34):opts.layer 配了层链的层走层链完整替换,其余走全局解析
|
|
250
|
+
const routes = await resolveLayerRoutes(ctx, cfg, opts.layer);
|
|
172
251
|
let lastErr;
|
|
173
252
|
for (let i = 0; i < routes.length; i++) {
|
|
174
253
|
const route = routes[i];
|
|
@@ -201,12 +280,16 @@ async function callRoute(ctx, cfg, opts, route) {
|
|
|
201
280
|
const user = opts.user.length > cfg.llm.maxInputChars
|
|
202
281
|
? `${opts.user.slice(0, cfg.llm.maxInputChars)}\n\n[输入超出 ${cfg.llm.maxInputChars} 字符预算,已截断]`
|
|
203
282
|
: opts.user;
|
|
204
|
-
// 输出预算 ×4
|
|
205
|
-
// (
|
|
206
|
-
//
|
|
283
|
+
// 输出预算 ×4 防线跟随【实际发送】的档位:阶段侧已按该层放大触发档位
|
|
284
|
+
// (layerEffortTrigger:层链头候选 > 全局主路由候选)放大过,这里只补自动档
|
|
285
|
+
// ('' → 模型默认/高档)解析出高档时的欠放大缺口——两侧共用一张表与同一触发值,
|
|
286
|
+
// 配置本身就是高档时不再放大(防 ×16 双乘)
|
|
207
287
|
const baseMaxTokens = opts.maxTokens ?? cfg.llm.maxTokens;
|
|
208
288
|
const highTiers = HIGH_EFFORT_TIERS;
|
|
209
|
-
const
|
|
289
|
+
const triggerEffort = opts.layer
|
|
290
|
+
? layerEffortTrigger(cfg, layerKeyFor(opts.layer))
|
|
291
|
+
: cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
|
|
292
|
+
const maxTokens = highTiers.includes(effort.effort) && !highTiers.includes(triggerEffort)
|
|
210
293
|
? layerMaxTokens(baseMaxTokens, 'high')
|
|
211
294
|
: baseMaxTokens;
|
|
212
295
|
const stream = ctx.llm.stream({
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -60,6 +60,19 @@ export function effectiveCfg(cfg, live) {
|
|
|
60
60
|
...(b.l3 > 0 ? { l3: b.l3 } : {}),
|
|
61
61
|
}
|
|
62
62
|
: null;
|
|
63
|
+
// 运行时按层路由链(#34):非空层链逐层注入 layerChainsRuntime(解析侧层内第一
|
|
64
|
+
// 优先级);pinned = 部署锁,运行时层链与运行时全局链一并失效(同 chainMode 的
|
|
65
|
+
// pinned 处理);全部层为空 = 无注入(返回引用不变路径不受影响)。层键逐个
|
|
66
|
+
// ?.length 容忍部分键缺失(旧存量 settings / 手写覆盖可能只带部分层)
|
|
67
|
+
const lc = s?.distillLayerChains;
|
|
68
|
+
const layerPick = lc && !pinned
|
|
69
|
+
? {
|
|
70
|
+
...(lc.l1?.length ? { l1: lc.l1 } : {}),
|
|
71
|
+
...(lc.l2?.length ? { l2: lc.l2 } : {}),
|
|
72
|
+
...(lc.l3?.length ? { l3: lc.l3 } : {}),
|
|
73
|
+
}
|
|
74
|
+
: null;
|
|
75
|
+
const layerChains = layerPick && Object.keys(layerPick).length ? layerPick : null;
|
|
63
76
|
const maxInput = s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : null;
|
|
64
77
|
// 旧档位键的全局接管(含给静态回退条目盖章):仅非链模式保留(旧存量值兼容)
|
|
65
78
|
const fallbacksTakeover = !chainMode && eff && cfg.llm?.fallbacks?.length
|
|
@@ -70,7 +83,7 @@ export function effectiveCfg(cfg, live) {
|
|
|
70
83
|
const effortInject = live && !chainMode && (eff !== '' || Boolean(cfg.llm?.reasoningEffort))
|
|
71
84
|
? { reasoningEffort: eff }
|
|
72
85
|
: null;
|
|
73
|
-
if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject)
|
|
86
|
+
if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains)
|
|
74
87
|
return cfg;
|
|
75
88
|
return {
|
|
76
89
|
...cfg,
|
|
@@ -83,6 +96,7 @@ export function effectiveCfg(cfg, live) {
|
|
|
83
96
|
...(fallbacksTakeover ? { fallbacks: fallbacksTakeover } : {}),
|
|
84
97
|
...(chainEffort ? { primaryEffort: chainEffort } : {}),
|
|
85
98
|
...(chainFallbacks ? { fallbacks: chainFallbacks } : {}),
|
|
99
|
+
...(layerChains ? { layerChainsRuntime: layerChains } : {}),
|
|
86
100
|
},
|
|
87
101
|
};
|
|
88
102
|
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -22,8 +22,15 @@ export declare const DISTILL_CHAIN_MAX = 8;
|
|
|
22
22
|
* distillChain、不走本投影——旧键路径在未配链时按旧语义原样生效。
|
|
23
23
|
*/
|
|
24
24
|
export declare function projectDistillChain(s: Partial<MemoryLiveSettings> | undefined): DistillChainEntry[];
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* settings-set 写入门校验:返回错误文案(null = 通过)。
|
|
27
|
+
* opts.requireExplicitHead(#34 层链用):头行必须 provider+model 双显式——层键出现
|
|
28
|
+
* 即意图覆盖;"双空 = 跟随默认模型"是全局链独有语义("默认模型 + 自定义回退"的
|
|
29
|
+
* 诉求由全局 llm.fallbacks 承担),层链禁掉双空头,消除"层链头跟随哪套全局解析"的歧义。
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateDistillChain(chain: unknown, opts?: {
|
|
32
|
+
requireExplicitHead?: boolean;
|
|
33
|
+
}): string | null;
|
|
27
34
|
export interface LiveSettingsHandle {
|
|
28
35
|
/** settings 服务是否可用(不可用时 UI 侧隐藏开关面板) */
|
|
29
36
|
supported: boolean;
|
package/dist/settings.js
CHANGED
|
@@ -22,8 +22,13 @@ export function projectDistillChain(s) {
|
|
|
22
22
|
}
|
|
23
23
|
return [];
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* settings-set 写入门校验:返回错误文案(null = 通过)。
|
|
27
|
+
* opts.requireExplicitHead(#34 层链用):头行必须 provider+model 双显式——层键出现
|
|
28
|
+
* 即意图覆盖;"双空 = 跟随默认模型"是全局链独有语义("默认模型 + 自定义回退"的
|
|
29
|
+
* 诉求由全局 llm.fallbacks 承担),层链禁掉双空头,消除"层链头跟随哪套全局解析"的歧义。
|
|
30
|
+
*/
|
|
31
|
+
export function validateDistillChain(chain, opts) {
|
|
27
32
|
if (!Array.isArray(chain))
|
|
28
33
|
return 'distillChain 须为数组';
|
|
29
34
|
if (chain.length > DISTILL_CHAIN_MAX)
|
|
@@ -41,6 +46,8 @@ export function validateDistillChain(chain) {
|
|
|
41
46
|
if (!EFFORT_CHOICES.includes(eff))
|
|
42
47
|
return `第 ${i + 1} 行思考档位非法: ${eff || '(空)'}`;
|
|
43
48
|
if (i === 0) {
|
|
49
|
+
if (opts?.requireExplicitHead && (!p || !m))
|
|
50
|
+
return '主路由行必须显式选择供应商与模型(层链不支持跟随默认模型)';
|
|
44
51
|
if ((p && !m) || (!p && m))
|
|
45
52
|
return '主路由行 provider 与 model 须成对(双空 = 跟随默认模型)';
|
|
46
53
|
}
|
|
@@ -68,6 +75,7 @@ const ALWAYS_ON = {
|
|
|
68
75
|
distillChain: [],
|
|
69
76
|
distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 },
|
|
70
77
|
distillMaxInputChars: 0,
|
|
78
|
+
distillLayerChains: { l1: [], l2: [], l3: [] },
|
|
71
79
|
};
|
|
72
80
|
/**
|
|
73
81
|
* 进程内 scope 复用(fiber 重启重挂)。
|
|
@@ -88,6 +96,13 @@ let cachedUnwatch;
|
|
|
88
96
|
let cachedSvc;
|
|
89
97
|
export function liveSettingsSchema() {
|
|
90
98
|
const budget = () => Schema.number().min(0).max(1_000_000).default(0);
|
|
99
|
+
// 层链条目形状与 distillChain 相同(档位必填、'' = 跟随);写入校验另在
|
|
100
|
+
// settings-set 门做逐层 requireExplicitHead(schema 层只管形状默认,语义门在 host)
|
|
101
|
+
const chainEntry = () => Schema.object({
|
|
102
|
+
provider: Schema.string().default(''),
|
|
103
|
+
model: Schema.string().default(''),
|
|
104
|
+
reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
|
|
105
|
+
});
|
|
91
106
|
return Schema.object({
|
|
92
107
|
enabled: Schema.boolean().default(true),
|
|
93
108
|
capture: Schema.boolean().default(true),
|
|
@@ -96,11 +111,12 @@ export function liveSettingsSchema() {
|
|
|
96
111
|
reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
|
|
97
112
|
distillProvider: Schema.string().default(''),
|
|
98
113
|
distillModel: Schema.string().default(''),
|
|
99
|
-
distillChain: Schema.array(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
distillChain: Schema.array(chainEntry()).default([]),
|
|
115
|
+
distillLayerChains: Schema.object({
|
|
116
|
+
l1: Schema.array(chainEntry()).default([]),
|
|
117
|
+
l2: Schema.array(chainEntry()).default([]),
|
|
118
|
+
l3: Schema.array(chainEntry()).default([]),
|
|
119
|
+
}).default({ l1: [], l2: [], l3: [] }),
|
|
104
120
|
distillBudgets: Schema.object({
|
|
105
121
|
extract: budget(),
|
|
106
122
|
dedup: budget(),
|
|
@@ -219,23 +235,28 @@ function resolveSettings(value) {
|
|
|
219
235
|
const num = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
|
|
220
236
|
const rawBudgets = (v.distillBudgets ?? {});
|
|
221
237
|
// 路由链逐条防御:非对象条目剔除、超长截断、非法档位归空、超限截断到上限
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
const defuseChain = (raw) => {
|
|
239
|
+
const out = [];
|
|
240
|
+
if (!Array.isArray(raw))
|
|
241
|
+
return out;
|
|
242
|
+
for (const item of raw) {
|
|
243
|
+
if (out.length >= DISTILL_CHAIN_MAX)
|
|
244
|
+
break;
|
|
245
|
+
if (!item || typeof item !== 'object')
|
|
246
|
+
continue;
|
|
247
|
+
const e = item;
|
|
248
|
+
const eff = typeof e.reasoningEffort === 'string' && EFFORT_CHOICES.includes(e.reasoningEffort)
|
|
249
|
+
? e.reasoningEffort
|
|
250
|
+
: '';
|
|
251
|
+
out.push({
|
|
252
|
+
provider: typeof e.provider === 'string' ? e.provider.slice(0, 200) : '',
|
|
253
|
+
model: typeof e.model === 'string' ? e.model.slice(0, 200) : '',
|
|
254
|
+
reasoningEffort: eff,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
const rawLayer = (v.distillLayerChains ?? {});
|
|
239
260
|
return {
|
|
240
261
|
enabled: v.enabled !== false,
|
|
241
262
|
capture: v.capture !== false,
|
|
@@ -246,7 +267,12 @@ function resolveSettings(value) {
|
|
|
246
267
|
: '',
|
|
247
268
|
distillProvider: typeof v.distillProvider === 'string' ? v.distillProvider : '',
|
|
248
269
|
distillModel: typeof v.distillModel === 'string' ? v.distillModel : '',
|
|
249
|
-
distillChain:
|
|
270
|
+
distillChain: defuseChain(v.distillChain),
|
|
271
|
+
distillLayerChains: {
|
|
272
|
+
l1: defuseChain(rawLayer.l1),
|
|
273
|
+
l2: defuseChain(rawLayer.l2),
|
|
274
|
+
l3: defuseChain(rawLayer.l3),
|
|
275
|
+
},
|
|
250
276
|
distillBudgets: {
|
|
251
277
|
extract: num(rawBudgets.extract),
|
|
252
278
|
dedup: num(rawBudgets.dedup),
|
package/dist/stats.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface MemoryStatusSource {
|
|
|
28
28
|
export interface SessionInfoSource {
|
|
29
29
|
/** 召回统计(recall.ts 注册表;未发生检索的会话返回 undefined)。 */
|
|
30
30
|
recallStats(sessionId: string): RecallSessionStats | undefined;
|
|
31
|
+
/** 记忆上下文占用账本(context-occupancy 唯一权威实例;未注入过的会话返回 null)。 */
|
|
32
|
+
memoryOccupancy(sessionId: string): MemoryOccupancy | null;
|
|
33
|
+
/** 稳定区份额估算(旧会话回填用;缺省 = 装配未提供,回填隐藏)。 */
|
|
34
|
+
profileEstimate?(sessionId: string): number;
|
|
35
|
+
/** 召回份额回填(live surface 现扫,miss 读盘上日志;缺省 = null)。 */
|
|
36
|
+
recallEstimate?(sessionId: string): Promise<number | null> | number | null;
|
|
31
37
|
/** 蒸馏管线会话视图(runner:攒批进度/挂起切片/会话产出)。 */
|
|
32
38
|
runnerView(sessionId: string, mode: string): {
|
|
33
39
|
pendingSlice: number;
|
|
@@ -44,6 +50,7 @@ export interface SessionInfoSource {
|
|
|
44
50
|
vectorSearch: boolean;
|
|
45
51
|
};
|
|
46
52
|
}
|
|
53
|
+
import type { MemoryOccupancy } from './contract.js';
|
|
47
54
|
export type { MemoryStats } from './contract.js';
|
|
48
55
|
/** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
|
|
49
56
|
export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, stores: {
|
package/dist/stats.js
CHANGED
|
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { EFFORT_CHOICES, resolveDataDir } from './config.js';
|
|
13
13
|
import { effectiveCfg } from './pipeline/runner.js';
|
|
14
14
|
import { emptyRecallStats } from './hooks/recall.js';
|
|
15
|
-
import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, resolveModelEfforts, resolveModelRoute } from './llm.js';
|
|
15
|
+
import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
|
|
16
16
|
import { projectDistillChain, validateDistillChain } from './settings.js';
|
|
17
17
|
import { errDetail } from './util/filelog.js';
|
|
18
18
|
import { snapshotTokenCost } from './token-cost.js';
|
|
@@ -199,12 +199,36 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
199
199
|
const chat = stores.state.forFamily('chat');
|
|
200
200
|
const work = stores.state.forFamily('work');
|
|
201
201
|
const lastAt = Math.max(chat.lastExtractAt, work.lastExtractAt);
|
|
202
|
+
// 主对话模型的官方声明窗口(占用指示器分母;advisory 查询读本地快照且有缓存,
|
|
203
|
+
// 轮询热路径下稳态为 Map 命中——遵守本端点"只许内存注册表读取"的硬规则口径。
|
|
204
|
+
// race 封顶防第三方适配器 resolveModelInfo 挂起拖死轮询——llm-models 端点同款先例)
|
|
205
|
+
let contextWindowTokens = null;
|
|
206
|
+
try {
|
|
207
|
+
const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
208
|
+
if (sel?.provider && sel?.model) {
|
|
209
|
+
contextWindowTokens = await Promise.race([
|
|
210
|
+
resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
|
|
211
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* 可选服务缺失/解析失败 = 分母未知,UI 降级 */
|
|
217
|
+
}
|
|
202
218
|
const v = {
|
|
203
219
|
supported: true,
|
|
204
220
|
sessionId,
|
|
205
221
|
mode,
|
|
206
222
|
defaultMode: modes?.default ?? cfg.family,
|
|
207
223
|
recall: { enabled: recallOn, ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()) },
|
|
224
|
+
memoryOccupancy: sessionInfo.memoryOccupancy(sessionId),
|
|
225
|
+
occupancyBackfill: sessionInfo.profileEstimate
|
|
226
|
+
? {
|
|
227
|
+
recallTokens: sessionInfo.recallEstimate ? await sessionInfo.recallEstimate(sessionId) : null,
|
|
228
|
+
profileTokens: sessionInfo.profileEstimate(sessionId),
|
|
229
|
+
}
|
|
230
|
+
: null,
|
|
231
|
+
contextWindowTokens,
|
|
208
232
|
distill: distillView,
|
|
209
233
|
l0Count,
|
|
210
234
|
retrieval: caps.vectorSearch ? (caps.ftsSearch ? 'hybrid' : 'vector') : caps.ftsSearch ? 'keyword' : 'none',
|
|
@@ -246,6 +270,7 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
246
270
|
enabled: true, capture: true, distill: true, recall: true,
|
|
247
271
|
reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
|
|
248
272
|
distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 }, distillMaxInputChars: 0,
|
|
273
|
+
distillLayerChains: { l1: [], l2: [], l3: [] },
|
|
249
274
|
},
|
|
250
275
|
// 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
|
|
251
276
|
ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
|
|
@@ -293,6 +318,27 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
293
318
|
throw new Error(err);
|
|
294
319
|
clean.distillChain = patch.distillChain;
|
|
295
320
|
}
|
|
321
|
+
// 运行时按层路由链(#34):逐层校验(头行必须显式——层覆盖不支持跟随默认模型);
|
|
322
|
+
// patch 语义只带要改的层,写入侧与存量层合并后落盘(空数组 = 该层回到跟随)
|
|
323
|
+
if (patch.distillLayerChains !== undefined) {
|
|
324
|
+
const rawLC = (patch.distillLayerChains ?? {});
|
|
325
|
+
// 与存量层合并后落全三键 Record(settings 视图类型是全量;缺层 = 清空该层跟随)
|
|
326
|
+
const prev = (live.get().distillLayerChains ?? {});
|
|
327
|
+
const merged = {
|
|
328
|
+
l1: prev.l1 ?? [],
|
|
329
|
+
l2: prev.l2 ?? [],
|
|
330
|
+
l3: prev.l3 ?? [],
|
|
331
|
+
};
|
|
332
|
+
for (const key of ['l1', 'l2', 'l3']) {
|
|
333
|
+
if (rawLC[key] === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
const err = validateDistillChain(rawLC[key], { requireExplicitHead: true });
|
|
336
|
+
if (err)
|
|
337
|
+
throw new Error(`层路由 ${key}:${err}`);
|
|
338
|
+
merged[key] = rawLC[key];
|
|
339
|
+
}
|
|
340
|
+
clean.distillLayerChains = merged;
|
|
341
|
+
}
|
|
296
342
|
if (patch.reasoningEffort !== undefined) {
|
|
297
343
|
const v = String(patch.reasoningEffort);
|
|
298
344
|
// 白名单与 schema/settings 同源(config.ts EFFORT_CHOICES)——此前此处漏扩词表,
|
|
@@ -452,20 +498,35 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
452
498
|
const chainCurrent = projectDistillChain(s);
|
|
453
499
|
let effectiveChain = [];
|
|
454
500
|
let effective = null;
|
|
501
|
+
let cfgView = cfg;
|
|
455
502
|
try {
|
|
456
|
-
|
|
503
|
+
cfgView = effectiveCfg(cfg, live);
|
|
457
504
|
effective = await resolveModelRoute(deps.ctx, cfgView);
|
|
458
505
|
effectiveChain = buildRouteChain({ provider: effective.provider, model: effective.model, effort: cfgView.llm.primaryEffort || '' }, cfgView.llm.fallbacks, cfgView.llm.reasoningEffort);
|
|
459
506
|
}
|
|
460
507
|
catch {
|
|
461
508
|
effective = null; // 无法解析(无默认选择且未覆盖)时 UI 显示占位
|
|
462
509
|
}
|
|
510
|
+
const pinned = Boolean(cfg.llm.provider && cfg.llm.model);
|
|
511
|
+
// 按层层链视图:与解析真值同径(layerChainOrNull 吃 effectiveCfg 之后的 cfgView,
|
|
512
|
+
// pinned 时运行时层链未注入、静态层链胜出;跟随层直接复用全局 effectiveChain)
|
|
513
|
+
const mkLayerView = (key) => {
|
|
514
|
+
const rt = s?.distillLayerChains?.[key] ?? [];
|
|
515
|
+
const lr = layerChainOrNull(cfgView, key);
|
|
516
|
+
const rtLive = !pinned && rt.length > 0 && !!rt[0].provider && !!rt[0].model;
|
|
517
|
+
return {
|
|
518
|
+
runtime: rt,
|
|
519
|
+
static: cfg.llm.layerRoutes?.[key] ?? [],
|
|
520
|
+
effectiveChain: lr ?? effectiveChain,
|
|
521
|
+
source: rtLive ? 'runtime' : lr ? 'static' : 'global',
|
|
522
|
+
};
|
|
523
|
+
};
|
|
463
524
|
const resp = {
|
|
464
525
|
supported: true,
|
|
465
526
|
providers,
|
|
466
527
|
default: def,
|
|
467
528
|
// 部署静态 pin(provider+model 双字段)优先于运行时选择,UI 据此禁用选择器
|
|
468
|
-
pinned
|
|
529
|
+
pinned,
|
|
469
530
|
current,
|
|
470
531
|
// 所选供应商是否仍在已注册路由中(用户删掉供应商后提示回退)
|
|
471
532
|
currentRegistered: current.provider === '' || providers.some((p) => p.id === current.provider),
|
|
@@ -476,6 +537,13 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
476
537
|
effectiveChain,
|
|
477
538
|
source: chainCurrent.length ? 'runtime' : 'static',
|
|
478
539
|
},
|
|
540
|
+
// 按层层链(#34):source 三态与解析真值同径(layerChainOrNull)——pinned 下
|
|
541
|
+
// 运行时层链不生效(与 effectiveCfg 注入条件一致),存量照实返回供 UI 展示
|
|
542
|
+
layerChains: {
|
|
543
|
+
l1: mkLayerView('l1'),
|
|
544
|
+
l2: mkLayerView('l2'),
|
|
545
|
+
l3: mkLayerView('l3'),
|
|
546
|
+
},
|
|
479
547
|
};
|
|
480
548
|
return resp;
|
|
481
549
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
import type { OccupancyLedger } from '../util/context-occupancy.js';
|
|
3
|
+
/** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
|
|
4
|
+
export declare const OCCUPANCY_SESSION_CAP = 200;
|
|
5
|
+
export declare class OccupancyStore {
|
|
6
|
+
private readonly logger?;
|
|
7
|
+
private readonly file;
|
|
8
|
+
private readonly entries;
|
|
9
|
+
private persistFailed;
|
|
10
|
+
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
11
|
+
private writeChain;
|
|
12
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
13
|
+
/** 载入持久化账目(合并进内存——构造与载入之间发生的 save 不丢);失败降级内存态。 */
|
|
14
|
+
private init;
|
|
15
|
+
/**
|
|
16
|
+
* 该会话的持久化账目(热路径同步读;从未注入/已复位返回 null)。
|
|
17
|
+
* 返回**浅拷贝**——调用方(ledgerFor 复生)会在其上做迁移;若交出内部引用,
|
|
18
|
+
* 原地修改会让 save() 的 prev/ledger 恒等比较误判“数值未变”而跳过写穿(实测踩坑)。
|
|
19
|
+
*/
|
|
20
|
+
load(sessionId: string): OccupancyLedger | null;
|
|
21
|
+
/**
|
|
22
|
+
* 迁移后写穿。数值未变只刷新内存时间戳不落盘(profile 稳定区每次请求组装都
|
|
23
|
+
* 触发迁移,但内容不变时不应产生文件写);stock 归零即删除条目。
|
|
24
|
+
*/
|
|
25
|
+
save(sessionId: string, ledger: OccupancyLedger): void;
|
|
26
|
+
/** 等待在途持久化写完成(测试/停机用)。 */
|
|
27
|
+
flush(): Promise<void>;
|
|
28
|
+
private persist;
|
|
29
|
+
private serialize;
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|