dsh-prime-memory 0.11.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.en.md +28 -0
- package/CHANGELOG.ja.md +30 -0
- package/CHANGELOG.ko.md +30 -0
- package/CHANGELOG.md +1220 -0
- package/ENGINEERING-NOTES.md +452 -0
- package/INSTALL.en.md +92 -0
- package/INSTALL.ja.md +92 -0
- package/INSTALL.ko.md +92 -0
- package/INSTALL.md +92 -0
- package/LICENSE +21 -0
- package/README.en.md +458 -0
- package/README.ja.md +306 -0
- package/README.ko.md +306 -0
- package/README.md +425 -0
- package/assets/changelog/0.8.10/01-write-only-pill.png +0 -0
- 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/assets/img/EmbeddingSource.png +0 -0
- package/assets/img/Hero.png +0 -0
- package/assets/img/Layers.png +0 -0
- package/assets/img/MemoryTools.png +0 -0
- package/assets/img/Modes.png +0 -0
- package/assets/img/ToolTrajectory.png +0 -0
- package/assets/img/ui-dark.jpg +0 -0
- package/assets/img/ui-light.jpg +0 -0
- package/assets/readme/bench-dialog.svg +70 -0
- package/assets/readme/bench-workflow.svg +79 -0
- package/assets/readme/flow.svg +189 -0
- package/assets/readme/storage.svg +115 -0
- package/cordis.patch.yml +16 -0
- package/dist/bench-control.d.ts +34 -0
- package/dist/bench-control.js +16 -0
- package/dist/client.js +4293 -0
- package/dist/config.d.ts +683 -0
- package/dist/config.js +129 -0
- package/dist/contract.d.ts +820 -0
- package/dist/contract.js +1 -0
- package/dist/embedding-worker.cjs +176 -0
- package/dist/graph/apply.d.ts +37 -0
- package/dist/graph/apply.js +270 -0
- package/dist/graph/constraints.d.ts +47 -0
- package/dist/graph/constraints.js +38 -0
- package/dist/graph/search.d.ts +16 -0
- package/dist/graph/search.js +115 -0
- package/dist/graph/types.d.ts +142 -0
- package/dist/graph/types.js +14 -0
- package/dist/hooks/capture.d.ts +32 -0
- package/dist/hooks/capture.js +194 -0
- package/dist/hooks/recall.d.ts +63 -0
- package/dist/hooks/recall.js +429 -0
- package/dist/index.d.ts +534 -0
- package/dist/index.js +344 -0
- package/dist/llm-usage.d.ts +26 -0
- package/dist/llm-usage.js +37 -0
- package/dist/llm.d.ts +153 -0
- package/dist/llm.js +530 -0
- package/dist/pipeline/graph.d.ts +35 -0
- package/dist/pipeline/graph.js +104 -0
- package/dist/pipeline/l1.d.ts +19 -0
- package/dist/pipeline/l1.js +271 -0
- package/dist/pipeline/l2.d.ts +13 -0
- package/dist/pipeline/l2.js +83 -0
- package/dist/pipeline/l3.d.ts +15 -0
- package/dist/pipeline/l3.js +78 -0
- package/dist/pipeline/rebuild.d.ts +61 -0
- package/dist/pipeline/rebuild.js +307 -0
- package/dist/pipeline/ruminate.d.ts +89 -0
- package/dist/pipeline/ruminate.js +298 -0
- package/dist/pipeline/runner.d.ts +167 -0
- package/dist/pipeline/runner.js +638 -0
- package/dist/pipeline/trigger.d.ts +40 -0
- package/dist/pipeline/trigger.js +75 -0
- package/dist/prompts/graph-projection.d.ts +70 -0
- package/dist/prompts/graph-projection.js +167 -0
- package/dist/prompts/l1-dedup.d.ts +22 -0
- package/dist/prompts/l1-dedup.js +251 -0
- package/dist/prompts/l1-extraction.d.ts +22 -0
- package/dist/prompts/l1-extraction.js +457 -0
- package/dist/prompts/persona.d.ts +23 -0
- package/dist/prompts/persona.js +240 -0
- package/dist/prompts/scene.d.ts +32 -0
- package/dist/prompts/scene.js +414 -0
- package/dist/runtime-package-lock.json +982 -0
- package/dist/settings.d.ts +50 -0
- package/dist/settings.js +355 -0
- package/dist/stats.d.ts +109 -0
- package/dist/stats.js +929 -0
- package/dist/store/bm25.d.ts +19 -0
- package/dist/store/bm25.js +63 -0
- package/dist/store/cost-ledger.d.ts +75 -0
- package/dist/store/cost-ledger.js +171 -0
- package/dist/store/download-queue.d.ts +79 -0
- package/dist/store/download-queue.js +424 -0
- package/dist/store/embedding-source.d.ts +118 -0
- package/dist/store/embedding-source.js +443 -0
- package/dist/store/embedding.d.ts +90 -0
- package/dist/store/embedding.js +206 -0
- package/dist/store/graph-store.d.ts +94 -0
- package/dist/store/graph-store.js +641 -0
- package/dist/store/l0.d.ts +40 -0
- package/dist/store/l0.js +197 -0
- package/dist/store/l1.d.ts +93 -0
- package/dist/store/l1.js +297 -0
- package/dist/store/local-embedding.d.ts +89 -0
- package/dist/store/local-embedding.js +227 -0
- package/dist/store/model-catalog.d.ts +48 -0
- package/dist/store/model-catalog.js +81 -0
- package/dist/store/occupancy.d.ts +30 -0
- package/dist/store/occupancy.js +134 -0
- package/dist/store/pending.d.ts +36 -0
- package/dist/store/pending.js +103 -0
- package/dist/store/persona.d.ts +15 -0
- package/dist/store/persona.js +60 -0
- package/dist/store/recall-dedupe.d.ts +26 -0
- package/dist/store/recall-dedupe.js +138 -0
- package/dist/store/runtime-installer.d.ts +59 -0
- package/dist/store/runtime-installer.js +243 -0
- package/dist/store/scenes.d.ts +24 -0
- package/dist/store/scenes.js +160 -0
- package/dist/store/search-utils.d.ts +38 -0
- package/dist/store/search-utils.js +100 -0
- package/dist/store/session-modes.d.ts +35 -0
- package/dist/store/session-modes.js +144 -0
- package/dist/store/sqlite.d.ts +246 -0
- package/dist/store/sqlite.js +1491 -0
- package/dist/store/state.d.ts +41 -0
- package/dist/store/state.js +72 -0
- package/dist/token-cost.d.ts +23 -0
- package/dist/token-cost.js +185 -0
- package/dist/tools/index.d.ts +34 -0
- package/dist/tools/index.js +758 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.js +38 -0
- package/dist/util/context-occupancy.d.ts +68 -0
- package/dist/util/context-occupancy.js +92 -0
- package/dist/util/filelog.d.ts +6 -0
- package/dist/util/filelog.js +108 -0
- package/dist/util/io.d.ts +18 -0
- package/dist/util/io.js +97 -0
- package/dist/util/recall-budget.d.ts +31 -0
- package/dist/util/recall-budget.js +84 -0
- package/dist/util/sanitize.d.ts +11 -0
- package/dist/util/sanitize.js +67 -0
- package/dist/util/text.d.ts +16 -0
- package/dist/util/text.js +61 -0
- package/dist/util/tokenizer.d.ts +9 -0
- package/dist/util/tokenizer.js +50 -0
- package/dsh.plugin.json +22 -0
- package/package.json +118 -0
package/dist/llm.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { errDetail } from './util/filelog.js';
|
|
3
|
+
import { recordDistillCall } from './llm-usage.js';
|
|
4
|
+
import { recordCostCall } from './token-cost.js';
|
|
5
|
+
// ── 分层输出预算(规格 C 节):结构化蒸馏用不到总闸级预算,逐层设护栏;
|
|
6
|
+
// 模型跑偏时单次损失有界。数值"先试跑"状态,按线上截断率调整。
|
|
7
|
+
/** L1 抽取(大输入块的 JSON 记忆数组输出)。 */
|
|
8
|
+
export const LAYER_MAX_TOKENS_EXTRACT = 16_000;
|
|
9
|
+
/** L1 去重(合并决策数组,输出比抽取短)。 */
|
|
10
|
+
export const LAYER_MAX_TOKENS_DEDUP = 8_000;
|
|
11
|
+
/** L2 场景整合(完整场景 Markdown 文件输出,输出最重的层)。 */
|
|
12
|
+
export const LAYER_MAX_TOKENS_L2 = 32_000;
|
|
13
|
+
/** L3 画像(完整 persona 文档)。 */
|
|
14
|
+
export const LAYER_MAX_TOKENS_L3 = 16_000;
|
|
15
|
+
/** 图谱投影(节点/边提案 JSON;单批 ≤8 条记录,输出比 L1 抽取短)。 */
|
|
16
|
+
export const LAYER_MAX_TOKENS_GRAPH = 8_000;
|
|
17
|
+
/** 各层内置默认预算(设置页"0 = 跟随默认"的默认值来源)。 */
|
|
18
|
+
export const LAYER_DEFAULT_BUDGETS = {
|
|
19
|
+
extract: LAYER_MAX_TOKENS_EXTRACT,
|
|
20
|
+
dedup: LAYER_MAX_TOKENS_DEDUP,
|
|
21
|
+
l2: LAYER_MAX_TOKENS_L2,
|
|
22
|
+
l3: LAYER_MAX_TOKENS_L3,
|
|
23
|
+
graph: LAYER_MAX_TOKENS_GRAPH,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* 解析某蒸馏层的生效输出预算:运行时覆盖(cfg.llm.budgets,0/缺省 = 跟随)
|
|
27
|
+
* → 内置默认 → 思考档放大(high/xhigh/max ×4,reasoning 计入输出预算的历史事故
|
|
28
|
+
* 防线)。放大触发档位跟层走:层链头档位候选 > 全局主路由档位候选;
|
|
29
|
+
* graph 层无层链(不落 l1 路由),恒走全局候选。
|
|
30
|
+
*/
|
|
31
|
+
export function resolveLayerTokens(cfg, layer) {
|
|
32
|
+
const override = cfg.llm.budgets?.[layer];
|
|
33
|
+
const key = layer === 'l2' ? 'l2' : layer === 'l3' ? 'l3' : layer === 'graph' ? null : 'l1';
|
|
34
|
+
return layerMaxTokens(override && override > 0 ? override : LAYER_DEFAULT_BUDGETS[layer], layerEffortTrigger(cfg, key));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 高思考档集合(输出预算 ×4 的档位):阶段侧 layerMaxTokens 与 callLLM 的
|
|
38
|
+
* 自动档防线共用同一张表——勿再在别处抄写该列表。
|
|
39
|
+
*/
|
|
40
|
+
export const HIGH_EFFORT_TIERS = ['high', 'xhigh', 'max'];
|
|
41
|
+
/**
|
|
42
|
+
* 思考档预算放大:reasoning 计入输出预算(v4-flash 事故:high 思考可吃光全部
|
|
43
|
+
* 预算致正文 0 字符)——effort 为 high/xhigh/max 时分层预算 ×4。
|
|
44
|
+
*/
|
|
45
|
+
export function layerMaxTokens(base, reasoningEffort) {
|
|
46
|
+
return HIGH_EFFORT_TIERS.includes(reasoningEffort) ? base * 4 : base;
|
|
47
|
+
}
|
|
48
|
+
/** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
|
|
49
|
+
export async function resolveModelRoute(ctx, cfg) {
|
|
50
|
+
if (cfg.llm.provider && cfg.llm.model) {
|
|
51
|
+
return { provider: cfg.llm.provider, model: cfg.llm.model };
|
|
52
|
+
}
|
|
53
|
+
// agentDefaultModel 是可选服务,缺失时不得抛错。
|
|
54
|
+
const defaults = ctx.get('agentDefaultModel');
|
|
55
|
+
const sel = defaults?.currentSelection?.();
|
|
56
|
+
if (sel?.provider && sel?.model) {
|
|
57
|
+
return { provider: cfg.llm.provider || sel.provider, model: cfg.llm.model || sel.model };
|
|
58
|
+
}
|
|
59
|
+
throw new Error('无法解析蒸馏模型路由:请在插件 config 中配置 llm.provider / llm.model,或确保存在默认模型选择');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 组装蒸馏路由链(纯决策,决策表缝):主路由在前、回退条目按配置顺序在后。
|
|
63
|
+
* provider/model 缺失的条目剔除;与主路由或先前条目完全相同(provider+model)的
|
|
64
|
+
* 条目跳过——注定失败的重复尝试不值得占位。每条路由携带生效档位候选:
|
|
65
|
+
* 主路由可带显式档位(运行时统一链注入 primaryEffort),条目档位非空覆盖全局。
|
|
66
|
+
*/
|
|
67
|
+
export function buildRouteChain(primary, fallbacks, globalEffort) {
|
|
68
|
+
const routes = [{ ...primary, effort: primary.effort || globalEffort }];
|
|
69
|
+
const seen = new Set([`${primary.provider}::${primary.model}`]);
|
|
70
|
+
for (const f of fallbacks ?? []) {
|
|
71
|
+
if (!f.provider || !f.model)
|
|
72
|
+
continue;
|
|
73
|
+
const key = `${f.provider}::${f.model}`;
|
|
74
|
+
if (seen.has(key))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
routes.push({ provider: f.provider, model: f.model, effort: f.reasoningEffort || globalEffort });
|
|
78
|
+
}
|
|
79
|
+
return routes;
|
|
80
|
+
}
|
|
81
|
+
// ── 按层独立路由(ADR-0005):层内三级 运行时层链 > 静态层链 > 全局解析 ──
|
|
82
|
+
// 层链非空即完整替换该层解析(该层主路由与回退都归层链管,全局链对该层不参与);
|
|
83
|
+
// 档位是全局偏好轴:层链空档位条目/头行仍回退全局档位。pin 只废运行时侧
|
|
84
|
+
// (effectiveCfg 不注入),静态层链天然穿透。
|
|
85
|
+
/**
|
|
86
|
+
* DistillLayer(调用点五键)→ 路由层键(三键):l1-extract/l1-dedup 同属 l1;
|
|
87
|
+
* graph 无层路由键——返回 null,调用侧按"该层无层链"回全局解析,绝不落入 l1
|
|
88
|
+
* (图谱投影误用 L1 抽取链是路由配置错误,静默错路由最难排查)。
|
|
89
|
+
*/
|
|
90
|
+
export function layerKeyFor(layer) {
|
|
91
|
+
return layer === 'l2' ? 'l2' : layer === 'l3' ? 'l3' : layer === 'graph' ? null : 'l1';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 取某层的生效层链(运行时优先)。头行残缺(provider/model 缺失——手写 YAML 错误
|
|
95
|
+
* 或防御性解析后的空链头)视为该层未配置、回退全局解析:路由配置错误不致该层
|
|
96
|
+
* 蒸馏失产,与回退链"条目缺失剔除"同一防御姿态。
|
|
97
|
+
*/
|
|
98
|
+
function layerChainOf(cfg, key) {
|
|
99
|
+
const rt = cfg.llm.layerChainsRuntime?.[key];
|
|
100
|
+
if (rt?.length && rt[0].provider && rt[0].model)
|
|
101
|
+
return rt;
|
|
102
|
+
const st = cfg.llm.layerRoutes?.[key];
|
|
103
|
+
if (st?.length && st[0].provider && st[0].model)
|
|
104
|
+
return st;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/** 该层的预算放大触发档位:层链头档位候选 > 全局主路由档位候选(primaryEffort > 静态全局)。
|
|
108
|
+
* key=null(graph 层无层链)→ 恒走全局候选。 */
|
|
109
|
+
export function layerEffortTrigger(cfg, key) {
|
|
110
|
+
const chain = key ? layerChainOf(cfg, key) : undefined;
|
|
111
|
+
return chain
|
|
112
|
+
? chain[0].reasoningEffort || cfg.llm.primaryEffort || cfg.llm.reasoningEffort
|
|
113
|
+
: cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 解析某次蒸馏调用的实际路由链(callLLM 入口):有层标签且该层配了层链 → 层链
|
|
117
|
+
* 完整替换(buildRouteChain 复用:头行在前、条目去重、档位三级候选);否则现行
|
|
118
|
+
* 全局解析。layer 缺省(bench/测试缝)= 全局解析。
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveLayerRoutes(ctx, cfg, layer) {
|
|
121
|
+
const key = layer ? layerKeyFor(layer) : undefined;
|
|
122
|
+
const lr = key ? layerChainOrNull(cfg, key) : null;
|
|
123
|
+
if (lr)
|
|
124
|
+
return lr;
|
|
125
|
+
const primary = await resolveModelRoute(ctx, cfg);
|
|
126
|
+
return buildRouteChain(
|
|
127
|
+
// 主路由显式档位来自运行时统一链(primaryEffort,'' = 跟随全局静态)
|
|
128
|
+
{ provider: primary.provider, model: primary.model, effort: cfg.llm.primaryEffort || '' }, cfg.llm.fallbacks, cfg.llm.reasoningEffort);
|
|
129
|
+
}
|
|
130
|
+
/** 层链解析的同步半边(llm-providers 视图与 resolveLayerRoutes 共用一条真值路径):
|
|
131
|
+
* 该层配了有效层链 → 完整链;null = 该层跟随全局解析。 */
|
|
132
|
+
export function layerChainOrNull(cfg, key) {
|
|
133
|
+
const chain = layerChainOf(cfg, key);
|
|
134
|
+
if (!chain)
|
|
135
|
+
return null;
|
|
136
|
+
return buildRouteChain({ provider: chain[0].provider, model: chain[0].model, effort: chain[0].reasoningEffort || '' }, chain.slice(1), cfg.llm.reasoningEffort);
|
|
137
|
+
}
|
|
138
|
+
/** 单路由持续失败的一次性告警去重表(拓扑变化随能力缓存一起失效)。 */
|
|
139
|
+
const routeDeadWarned = new Set();
|
|
140
|
+
function warnRouteDeadOnce(route, logger) {
|
|
141
|
+
if (!logger)
|
|
142
|
+
return;
|
|
143
|
+
const key = `${route.provider}::${route.model}`;
|
|
144
|
+
if (routeDeadWarned.has(key))
|
|
145
|
+
return;
|
|
146
|
+
routeDeadWarned.add(key);
|
|
147
|
+
logger.warn(`[memory] 蒸馏路由 ${route.provider}/${route.model} 失败(每路由仅告警一次;逐次失败原因与降级去向见后续日志)`);
|
|
148
|
+
}
|
|
149
|
+
const effortCache = new Map();
|
|
150
|
+
/** 清空能力缓存(llm/adapters-updated 时调用:供应商增删/改配置后重新探询)。 */
|
|
151
|
+
export function invalidateEffortCache() {
|
|
152
|
+
effortCache.clear();
|
|
153
|
+
contextWindowCache.clear();
|
|
154
|
+
effortWarned.clear();
|
|
155
|
+
routeDeadWarned.clear();
|
|
156
|
+
}
|
|
157
|
+
/** 探询某模型的思考档位能力;失败返回 null(调用方保持旧发送行为,不改判)。 */
|
|
158
|
+
export async function resolveModelEfforts(ctx, provider, model) {
|
|
159
|
+
const key = `${provider}::${model}`;
|
|
160
|
+
const hit = effortCache.get(key);
|
|
161
|
+
if (hit)
|
|
162
|
+
return hit;
|
|
163
|
+
try {
|
|
164
|
+
if (typeof ctx.llm?.resolveModelInfo !== 'function')
|
|
165
|
+
return null;
|
|
166
|
+
const info = await ctx.llm.resolveModelInfo(provider, model);
|
|
167
|
+
const efforts = (info.reasoning?.efforts ?? [])
|
|
168
|
+
.map((e) => String(e.id))
|
|
169
|
+
.filter((id) => id.length > 0);
|
|
170
|
+
const cap = {
|
|
171
|
+
efforts,
|
|
172
|
+
...(info.reasoning?.defaultEffort ? { defaultEffort: String(info.reasoning.defaultEffort) } : {}),
|
|
173
|
+
};
|
|
174
|
+
effortCache.set(key, cap);
|
|
175
|
+
return cap;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null; // 不缓存失败:路由尚未注册等瞬时态,下次调用重试
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** (provider, model) → 上下文窗口 token 数;advisory,未声明/失败为 null。 */
|
|
182
|
+
const contextWindowCache = new Map();
|
|
183
|
+
/**
|
|
184
|
+
* 探询某模型的上下文窗口容量(adapter 声明的 provider-owned capacity)。
|
|
185
|
+
* 与 effortCache 同源同失效策略;仅用于占用指示器的分母展示——分母必须与官方环
|
|
186
|
+
* 同源(模型声明值),禁止 client 自估。
|
|
187
|
+
*/
|
|
188
|
+
export async function resolveModelContextWindow(ctx, provider, model) {
|
|
189
|
+
const key = `${provider}::${model}`;
|
|
190
|
+
if (contextWindowCache.has(key))
|
|
191
|
+
return contextWindowCache.get(key) ?? null;
|
|
192
|
+
try {
|
|
193
|
+
if (typeof ctx.llm?.resolveModelInfo !== 'function')
|
|
194
|
+
return null;
|
|
195
|
+
const info = await ctx.llm.resolveModelInfo(provider, model);
|
|
196
|
+
const win = info.context?.contextWindow;
|
|
197
|
+
const val = typeof win === 'number' && Number.isFinite(win) && win > 0 ? Math.floor(win) : null;
|
|
198
|
+
contextWindowCache.set(key, val);
|
|
199
|
+
return val;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null; // 不缓存失败:下次调用重试
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** 纯决策:配置档位 + 模型能力 → 实际发送值(callLLM 与 settings-get 共用)。 */
|
|
206
|
+
export function decideSendableEffort(cap, cfgEffort) {
|
|
207
|
+
if (!cap)
|
|
208
|
+
return { effort: cfgEffort, reason: 'no-capability' };
|
|
209
|
+
if (cfgEffort) {
|
|
210
|
+
if (cap.efforts.includes(cfgEffort))
|
|
211
|
+
return { effort: cfgEffort, reason: 'supported' };
|
|
212
|
+
if (cfgEffort === 'off' && cap.efforts.includes('none'))
|
|
213
|
+
return { effort: 'none', reason: 'alias-none' };
|
|
214
|
+
if (cap.efforts.length === 0)
|
|
215
|
+
return { effort: '', reason: 'no-efforts' };
|
|
216
|
+
return { effort: '', reason: 'unsupported' };
|
|
217
|
+
}
|
|
218
|
+
// 空配置 = 自动:模型默认档 → 无默认取 high(未声明/无默认一律 high)→ 仍无则不传
|
|
219
|
+
if (cap.defaultEffort && cap.efforts.includes(cap.defaultEffort)) {
|
|
220
|
+
return { effort: cap.defaultEffort, reason: 'auto-default' };
|
|
221
|
+
}
|
|
222
|
+
if (cap.efforts.includes('high'))
|
|
223
|
+
return { effort: 'high', reason: 'auto-high' };
|
|
224
|
+
return { effort: '', reason: 'no-efforts' };
|
|
225
|
+
}
|
|
226
|
+
const effortWarned = new Set();
|
|
227
|
+
/** 探询 + 决策 + 一次性告警(不支持/未声明时提示降级,不刷屏)。 */
|
|
228
|
+
export async function planDistillEffort(ctx, provider, model, cfgEffort, logger) {
|
|
229
|
+
const cap = await resolveModelEfforts(ctx, provider, model);
|
|
230
|
+
const d = decideSendableEffort(cap, cfgEffort);
|
|
231
|
+
if ((d.reason === 'unsupported' || d.reason === 'no-efforts') && logger) {
|
|
232
|
+
const key = `${provider}::${model}::${cfgEffort}::${d.reason}`;
|
|
233
|
+
if (!effortWarned.has(key)) {
|
|
234
|
+
effortWarned.add(key);
|
|
235
|
+
logger.warn(`[memory] 蒸馏思考档位 ${cfgEffort || '(auto)'} 不被 ${provider}/${model} 支持` +
|
|
236
|
+
(d.reason === 'no-efforts'
|
|
237
|
+
? '(模型未声明思考档位)'
|
|
238
|
+
: `(支持: ${cap?.efforts.join('/')})`) +
|
|
239
|
+
',本次调用不传档位(跟随模型默认)');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return d;
|
|
243
|
+
}
|
|
244
|
+
// ── 直接压缩通道(llm.mode='direct'):与付费供应商解耦 ──
|
|
245
|
+
// 插件原生 HTTP 直连 OpenAI 兼容 /chat/completions 端点(可指向本地 ollama、私有网关、
|
|
246
|
+
// 免费档等),不依赖宿主 provider 注册表。direct 失败自动回退宿主路由链
|
|
247
|
+
// (callLLM 内处理),本地端点故障不致压缩失产。
|
|
248
|
+
/** direct 通道是否就绪(baseURL 与 model 齐才算配置完整)。 */
|
|
249
|
+
function directReady(cfg) {
|
|
250
|
+
return !!cfg.llm.baseURL && !!cfg.llm.model;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 插件原生 HTTP 的一次压缩调用(OpenAI 兼容)。非流式单请求回包,足够压缩场景;
|
|
254
|
+
* 超时/网络/非 2xx/空输出均按失败抛错(交由 callLLM 回退宿主路由)。
|
|
255
|
+
*/
|
|
256
|
+
async function callDirect(cfg, opts) {
|
|
257
|
+
const model = cfg.llm.model;
|
|
258
|
+
const base = cfg.llm.baseURL.replace(/\/+$/, '');
|
|
259
|
+
const url = `${base}/chat/completions`;
|
|
260
|
+
const layer = opts.layer;
|
|
261
|
+
// 输入预算兜底(与宿主路径同口径:单次蒸馏用户 prompt 不超过 maxInputChars)
|
|
262
|
+
const user = opts.user.length > cfg.llm.maxInputChars
|
|
263
|
+
? `${opts.user.slice(0, cfg.llm.maxInputChars)}\n\n[输入超出 ${cfg.llm.maxInputChars} 字符预算,已截断]`
|
|
264
|
+
: opts.user;
|
|
265
|
+
const signal = opts.signal ?? AbortSignal.timeout(cfg.llm.timeoutMs);
|
|
266
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
267
|
+
// 本地免 key 端点不设 Authorization 头;直连跨供应商不发送 reasoning_effort
|
|
268
|
+
// (openai-completions 风格端点无统一 effort 词汇表,交给端点默认行为)
|
|
269
|
+
if (cfg.llm.apiKey)
|
|
270
|
+
headers.Authorization = `Bearer ${cfg.llm.apiKey}`;
|
|
271
|
+
const startedAt = Date.now();
|
|
272
|
+
let res;
|
|
273
|
+
try {
|
|
274
|
+
res = await fetch(url, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers,
|
|
277
|
+
signal,
|
|
278
|
+
body: JSON.stringify({
|
|
279
|
+
model,
|
|
280
|
+
messages: [
|
|
281
|
+
{ role: 'system', content: opts.system },
|
|
282
|
+
{ role: 'user', content: user },
|
|
283
|
+
],
|
|
284
|
+
temperature: opts.temperature ?? cfg.llm.temperature,
|
|
285
|
+
max_tokens: opts.maxTokens ?? cfg.llm.maxTokens,
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (layer)
|
|
291
|
+
recordDistillCall(layer, user.length, 0, 0, true);
|
|
292
|
+
if (layer)
|
|
293
|
+
recordCostCall('direct', model, layer, user.length, 0, 0);
|
|
294
|
+
opts.logger?.warn(`[memory] direct 压缩调用网络失败 ${base}(${((Date.now() - startedAt) / 1000).toFixed(1)}s): ${errDetail(err)}`);
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
const detail = await res.text().catch(() => '');
|
|
299
|
+
if (layer)
|
|
300
|
+
recordDistillCall(layer, user.length, 0, 0, true);
|
|
301
|
+
if (layer)
|
|
302
|
+
recordCostCall('direct', model, layer, user.length, 0, 0);
|
|
303
|
+
opts.logger?.warn(`[memory] direct 压缩端点 HTTP ${res.status}(${base}): ${detail.slice(0, 400)}`);
|
|
304
|
+
throw new Error(`direct ${res.status}: ${detail.slice(0, 400)}`);
|
|
305
|
+
}
|
|
306
|
+
let data;
|
|
307
|
+
try {
|
|
308
|
+
data = (await res.json());
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (layer)
|
|
312
|
+
recordDistillCall(layer, user.length, 0, 0, true);
|
|
313
|
+
if (layer)
|
|
314
|
+
recordCostCall('direct', model, layer, user.length, 0, 0);
|
|
315
|
+
opts.logger?.warn(`[memory] direct 压缩端点 JSON 解析失败(${base}): ${errDetail(err)}`);
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const outputTokens = data?.usage?.completion_tokens ?? data?.usage?.output_tokens ?? 0;
|
|
319
|
+
const reasoningTokens = data?.usage?.completion_tokens_details?.reasoning_tokens ??
|
|
320
|
+
(data?.usage && 'reasoning_tokens' in data.usage ? data.usage.reasoning_tokens ?? 0 : 0);
|
|
321
|
+
const out = (data?.choices?.[0]?.message?.content ?? '').trim();
|
|
322
|
+
if (out.length === 0) {
|
|
323
|
+
if (layer)
|
|
324
|
+
recordDistillCall(layer, user.length, outputTokens, reasoningTokens, true);
|
|
325
|
+
if (layer)
|
|
326
|
+
recordCostCall('direct', model, layer, user.length, outputTokens, reasoningTokens);
|
|
327
|
+
opts.logger?.warn(`[memory] direct 压缩空输出(${base}/${model},输出 tokens=${outputTokens})`);
|
|
328
|
+
throw new Error(`direct empty output: ${base}/${model} 端点返回 0 字符`);
|
|
329
|
+
}
|
|
330
|
+
if (layer)
|
|
331
|
+
recordDistillCall(layer, user.length, outputTokens, reasoningTokens, false);
|
|
332
|
+
if (layer)
|
|
333
|
+
recordCostCall('direct', model, layer, user.length, outputTokens, reasoningTokens);
|
|
334
|
+
opts.logger?.info(`[memory] direct 压缩 ${base}/${model}:输入 ${user.length} 字符 → 输出 ${out.length} 字符(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* 一次完整蒸馏调用(带回退链,ADR-0004):按路由链(主路由 + llm.fallbacks)逐条
|
|
339
|
+
* 尝试,返回首个成功路由的输出。失败(error/aborted finish、网络异常、空输出)
|
|
340
|
+
* 降级下一条;调用方主动取消(signal 已中止)原样上抛不降级;全部失败抛最后一个
|
|
341
|
+
* 错误,由调用方兜底(runner 的按会话指数退避接管重试节奏)。
|
|
342
|
+
*
|
|
343
|
+
* llm.mode='direct' 时压缩首选走插件原生 HTTP(callDirect)——与付费 API 解耦;
|
|
344
|
+
* direct 失败(含未配置 baseURL/model)自动回退下方宿主路由链作兜底安全网。
|
|
345
|
+
*
|
|
346
|
+
* 文本只从 block-end(协议保证携带组装完成的整块)取;text-delta 仅在
|
|
347
|
+
* 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
|
|
348
|
+
*/
|
|
349
|
+
export async function callLLM(ctx, cfg, opts) {
|
|
350
|
+
let lastErr;
|
|
351
|
+
// direct 通道(plugin-native HTTP):压缩首选,失败落 host 兜底。
|
|
352
|
+
if (cfg.llm.mode === 'direct') {
|
|
353
|
+
if (directReady(cfg)) {
|
|
354
|
+
try {
|
|
355
|
+
return await callDirect(cfg, opts);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
// 调用方主动取消(重建取消/进程关闭)不是通道失败——不降级,原样上抛
|
|
359
|
+
if (opts.signal?.aborted)
|
|
360
|
+
throw err;
|
|
361
|
+
lastErr = err;
|
|
362
|
+
opts.logger?.warn(`[memory] direct 压缩失败,回退宿主路由(${errDetail(err)})`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
opts.logger?.warn('[memory] llm.mode=direct 但未配置 llm.baseURL/llm.model,回退宿主路由');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// 宿主路由链:opts.layer 配了层链的层走层链完整替换,其余走全局解析;
|
|
370
|
+
// direct 模式下这一段充当付费/默认模型的兜底安全网。
|
|
371
|
+
const routes = await resolveLayerRoutes(ctx, cfg, opts.layer);
|
|
372
|
+
for (let i = 0; i < routes.length; i++) {
|
|
373
|
+
const route = routes[i];
|
|
374
|
+
try {
|
|
375
|
+
return await callRoute(ctx, cfg, opts, route);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
// 调用方主动取消不是路由失败——不降级,原样上抛
|
|
379
|
+
if (opts.signal?.aborted)
|
|
380
|
+
throw err;
|
|
381
|
+
lastErr = err;
|
|
382
|
+
warnRouteDeadOnce(route, opts.logger);
|
|
383
|
+
const next = routes[i + 1];
|
|
384
|
+
if (next) {
|
|
385
|
+
opts.logger?.info(`[memory] 蒸馏路由降级 ${route.provider}/${route.model} → ${next.provider}/${next.model}(${errDetail(err)})`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw lastErr;
|
|
390
|
+
}
|
|
391
|
+
/** 单路由一次尝试(callLLM 循环体;每路由新建超时信号 = 各享全额 timeoutMs)。 */
|
|
392
|
+
async function callRoute(ctx, cfg, opts, route) {
|
|
393
|
+
const { provider, model } = route;
|
|
394
|
+
const signal = opts.signal ?? AbortSignal.timeout(cfg.llm.timeoutMs);
|
|
395
|
+
// 档位按模型能力决策(跨供应商 effort 兼容):不支持的档位不传 + 告警一次,
|
|
396
|
+
// 空配置 = 自动(模型默认 → high);路由的档位候选已在链解析时定好(条目 > 全局)
|
|
397
|
+
const effort = await planDistillEffort(ctx, provider, model, route.effort, opts.logger);
|
|
398
|
+
// 输入预算兜底:任何蒸馏调用的用户 prompt 不超过 maxInputChars
|
|
399
|
+
// (L1 已在数据层分块,这里是 L2/L3 与异常场景的最后一道网)
|
|
400
|
+
const user = opts.user.length > cfg.llm.maxInputChars
|
|
401
|
+
? `${opts.user.slice(0, cfg.llm.maxInputChars)}\n\n[输入超出 ${cfg.llm.maxInputChars} 字符预算,已截断]`
|
|
402
|
+
: opts.user;
|
|
403
|
+
// 输出预算 ×4 防线跟随【实际发送】的档位:阶段侧已按该层放大触发档位
|
|
404
|
+
// (layerEffortTrigger:层链头候选 > 全局主路由候选)放大过,这里只补自动档
|
|
405
|
+
// 解析出高档时的欠放大缺口——两侧共用一张表与同一触发值,
|
|
406
|
+
// 配置本身就是高档时不再放大(防 ×16 双乘)
|
|
407
|
+
const baseMaxTokens = opts.maxTokens ?? cfg.llm.maxTokens;
|
|
408
|
+
const highTiers = HIGH_EFFORT_TIERS;
|
|
409
|
+
const triggerEffort = opts.layer
|
|
410
|
+
? layerEffortTrigger(cfg, layerKeyFor(opts.layer))
|
|
411
|
+
: cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
|
|
412
|
+
const maxTokens = highTiers.includes(effort.effort) && !highTiers.includes(triggerEffort)
|
|
413
|
+
? layerMaxTokens(baseMaxTokens, 'high')
|
|
414
|
+
: baseMaxTokens;
|
|
415
|
+
const stream = ctx.llm.stream({
|
|
416
|
+
provider,
|
|
417
|
+
model,
|
|
418
|
+
system: opts.system,
|
|
419
|
+
messages: [createUserMessage({ content: [{ type: 'text', text: user }], source: { kind: 'user' } })],
|
|
420
|
+
temperature: opts.temperature ?? cfg.llm.temperature,
|
|
421
|
+
maxTokens,
|
|
422
|
+
// 档位只在能力决策给出非空值时传;空串不传(跟随模型默认)
|
|
423
|
+
...(effort.effort ? { reasoningEffort: ReasoningEffortId(effort.effort) } : {}),
|
|
424
|
+
signal,
|
|
425
|
+
});
|
|
426
|
+
const startedAt = Date.now();
|
|
427
|
+
let deltaText = '';
|
|
428
|
+
let blockText = '';
|
|
429
|
+
// 块级统计:空输出诊断的唯一现场(finish reason / token 计数 / reasoning 是否吃光预算)
|
|
430
|
+
let finishKind = '';
|
|
431
|
+
let outputTokens = 0;
|
|
432
|
+
let reasoningTokens = 0;
|
|
433
|
+
let deltaBlocks = 0;
|
|
434
|
+
let reasoningChars = 0;
|
|
435
|
+
let reasoningHead = '';
|
|
436
|
+
const blockEndTypes = new Map();
|
|
437
|
+
try {
|
|
438
|
+
for await (const chunk of stream) {
|
|
439
|
+
if (chunk.type === 'text-delta') {
|
|
440
|
+
deltaBlocks++;
|
|
441
|
+
deltaText += chunk.text;
|
|
442
|
+
}
|
|
443
|
+
else if (chunk.type === 'reasoning-delta') {
|
|
444
|
+
reasoningChars += chunk.text.length;
|
|
445
|
+
if (reasoningHead.length < 300)
|
|
446
|
+
reasoningHead += chunk.text.slice(0, 300 - reasoningHead.length);
|
|
447
|
+
}
|
|
448
|
+
else if (chunk.type === 'block-end') {
|
|
449
|
+
blockEndTypes.set(chunk.block.type, (blockEndTypes.get(chunk.block.type) ?? 0) + 1);
|
|
450
|
+
if (chunk.block.type === 'text')
|
|
451
|
+
blockText += chunk.block.text;
|
|
452
|
+
}
|
|
453
|
+
else if (chunk.type === 'usage') {
|
|
454
|
+
outputTokens = chunk.usage.outputTokens;
|
|
455
|
+
reasoningTokens = chunk.usage.reasoningTokens ?? 0;
|
|
456
|
+
}
|
|
457
|
+
else if (chunk.type === 'finish') {
|
|
458
|
+
finishKind = chunk.reason.kind;
|
|
459
|
+
if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
|
|
460
|
+
const failure = chunk.reason.failure;
|
|
461
|
+
throw new Error(`llm ${chunk.reason.kind}: ${failure?.message ?? 'unknown failure'}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
// 记账含失败路径(failures 计数;tokens 尽当时流内已到的 usage)
|
|
468
|
+
if (opts.layer)
|
|
469
|
+
recordDistillCall(opts.layer, user.length, outputTokens, reasoningTokens, true);
|
|
470
|
+
if (opts.layer)
|
|
471
|
+
recordCostCall(provider, model, opts.layer, user.length, outputTokens, reasoningTokens);
|
|
472
|
+
opts.logger?.warn(`[memory] LLM 调用失败 ${provider}/${model}(${((Date.now() - startedAt) / 1000).toFixed(1)}s): ${errDetail(err)}`);
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
const out = (blockText || deltaText).trim();
|
|
476
|
+
if (out.length === 0) {
|
|
477
|
+
// 空输出是最难排查的失败:流正常结束但一个字没吐。必须记录 finish 原因、
|
|
478
|
+
// token 计数与块分布,才能区分"模型只产出了 reasoning"vs"服务端返回空响应"。
|
|
479
|
+
if (opts.layer)
|
|
480
|
+
recordDistillCall(opts.layer, user.length, outputTokens, reasoningTokens, true);
|
|
481
|
+
if (opts.layer)
|
|
482
|
+
recordCostCall(provider, model, opts.layer, user.length, outputTokens, reasoningTokens);
|
|
483
|
+
opts.logger?.warn(`[memory] LLM 空输出 ${provider}/${model}(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无 finish 块'}` +
|
|
484
|
+
`,输出 tokens=${outputTokens}${reasoningTokens > 0 ? `/reasoning ${reasoningTokens}` : ''},` +
|
|
485
|
+
`text-delta ${deltaBlocks} 块/${deltaText.length} 字符,reasoning ${reasoningChars} 字符,` +
|
|
486
|
+
`block-end: ${[...blockEndTypes.entries()].map(([t, n]) => `${t}×${n}`).join(', ') || '无'})` +
|
|
487
|
+
(reasoningHead ? `,reasoning 摘录: ${reasoningHead}…` : ''));
|
|
488
|
+
// 空输出按路由失败处理:交给回退链降级或上抛——返回空串只是把失败
|
|
489
|
+
// 推迟到下游 JSON/Markdown 解析,诊断信息更差
|
|
490
|
+
throw new Error(`llm empty output: ${provider}/${model} 流正常结束但输出 0 字符`);
|
|
491
|
+
}
|
|
492
|
+
if (opts.layer)
|
|
493
|
+
recordDistillCall(opts.layer, user.length, outputTokens, reasoningTokens, false);
|
|
494
|
+
if (opts.layer)
|
|
495
|
+
recordCostCall(provider, model, opts.layer, user.length, outputTokens, reasoningTokens);
|
|
496
|
+
opts.logger?.info(`[memory] LLM 调用 ${provider}/${model}:输入 ${user.length} 字符 → 输出 ${out.length} 字符(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无'})`);
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
/** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)。 */
|
|
500
|
+
export function parseJsonLogged(raw, what, logger) {
|
|
501
|
+
try {
|
|
502
|
+
return parseJson(raw);
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
logger?.error(`[memory] ${what} JSON 解析失败(${errDetail(err)}),原始输出前 400 字符: ${raw.slice(0, 400)}`);
|
|
506
|
+
throw new Error(`${what} 输出无法解析为 JSON`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** 容错地解析 LLM 输出的 JSON(剥掉可能的 ```json 围栏)。 */
|
|
510
|
+
export function parseJson(raw) {
|
|
511
|
+
let s = raw.trim();
|
|
512
|
+
if (s.startsWith('```')) {
|
|
513
|
+
s = s.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
|
|
514
|
+
}
|
|
515
|
+
const start = s.indexOf('[');
|
|
516
|
+
const brace = s.indexOf('{');
|
|
517
|
+
let begin;
|
|
518
|
+
if (start === -1)
|
|
519
|
+
begin = brace;
|
|
520
|
+
else if (brace === -1)
|
|
521
|
+
begin = start;
|
|
522
|
+
else
|
|
523
|
+
begin = Math.min(start, brace);
|
|
524
|
+
if (begin > 0)
|
|
525
|
+
s = s.slice(begin);
|
|
526
|
+
const end = s.lastIndexOf(']') > s.lastIndexOf('}') ? s.lastIndexOf(']') + 1 : s.lastIndexOf('}') + 1;
|
|
527
|
+
if (end > 0)
|
|
528
|
+
s = s.slice(0, end);
|
|
529
|
+
return JSON.parse(s);
|
|
530
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图谱投影管线:claim → 上下文装配 → LLM 投影 → 纯函数校验落库。
|
|
3
|
+
*
|
|
4
|
+
* LLM 调用发生在事务外(GraphStore 的 claim/complete 是两个独立事务缝)——
|
|
5
|
+
* 单一 LLM 并发源仍是 runner 的串行泵,本文件不做任何并发控制。
|
|
6
|
+
* 上下文装配的纯函数(selectContextNodes/selectContextEdges)独立导出,
|
|
7
|
+
* 上下文有界性(节点 ≤80 / 边 ≤120 / 仅 active facts)是 G4 验收面。
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import type { MemoryConfig } from '../config.js';
|
|
11
|
+
import type { GraphStore } from '../store/graph-store.js';
|
|
12
|
+
import type { MemoryLogger, MemoryRecord } from '../types.js';
|
|
13
|
+
import type { GraphEdge, GraphNode, GraphProjectionResult } from '../graph/types.js';
|
|
14
|
+
/** claim 上下文装配的节点上限(词法相关 + 最近更新,总量封顶)。 */
|
|
15
|
+
export declare const GRAPH_CONTEXT_NODE_LIMIT = 80;
|
|
16
|
+
/** 保底携带的最近更新节点数(图谱冷启动后有"当前关注点"锚)。 */
|
|
17
|
+
export declare const GRAPH_CONTEXT_RECENT_NODES = 24;
|
|
18
|
+
/** claim 上下文装配的边上限。 */
|
|
19
|
+
export declare const GRAPH_CONTEXT_EDGE_LIMIT = 120;
|
|
20
|
+
/**
|
|
21
|
+
* 选择进入投影上下文的已有节点:与批内记录词法相关(名称/别名/标签与记录内容
|
|
22
|
+
* token 相交,归一去重)优先,再按更新时间新→旧补足最近节点,总量 ≤80。
|
|
23
|
+
* 词法相关的判定复用 normalizeEntityName + tokenize,不 includes。
|
|
24
|
+
*/
|
|
25
|
+
export declare function selectContextNodes(records: readonly MemoryRecord[], nodes: readonly GraphNode[], limit?: number): GraphNode[];
|
|
26
|
+
/** 选择进入上下文的边:两端都入选节点的 active 边,按更新时间新→旧,≤120。 */
|
|
27
|
+
export declare function selectContextEdges(nodes: readonly GraphNode[], edges: readonly GraphEdge[], limit?: number): GraphEdge[];
|
|
28
|
+
/**
|
|
29
|
+
* 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
|
|
30
|
+
* 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
|
|
31
|
+
* GraphStore 管),不向 runner 上抛。
|
|
32
|
+
*/
|
|
33
|
+
export declare function runGraphProjection(ctx: Context, cfg: MemoryConfig, graphs: GraphStore, logger: MemoryLogger): Promise<boolean>;
|
|
34
|
+
/** 投影输出解析与形状归一:非对象/缺数组字段回空结构(校验侧再逐条把关)。 */
|
|
35
|
+
export declare function parseProjection(raw: string, logger?: MemoryLogger): GraphProjectionResult;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
|
|
2
|
+
import { buildGraphProjectionPrompt, getGraphProjectionSystemPrompt, } from '../prompts/graph-projection.js';
|
|
3
|
+
import { normalizeEntityName } from '../graph/apply.js';
|
|
4
|
+
import { tokenize } from '../util/text.js';
|
|
5
|
+
import { errDetail } from '../util/filelog.js';
|
|
6
|
+
/** claim 上下文装配的节点上限(词法相关 + 最近更新,总量封顶)。 */
|
|
7
|
+
export const GRAPH_CONTEXT_NODE_LIMIT = 80;
|
|
8
|
+
/** 保底携带的最近更新节点数(图谱冷启动后有"当前关注点"锚)。 */
|
|
9
|
+
export const GRAPH_CONTEXT_RECENT_NODES = 24;
|
|
10
|
+
/** claim 上下文装配的边上限。 */
|
|
11
|
+
export const GRAPH_CONTEXT_EDGE_LIMIT = 120;
|
|
12
|
+
/**
|
|
13
|
+
* 选择进入投影上下文的已有节点:与批内记录词法相关(名称/别名/标签与记录内容
|
|
14
|
+
* token 相交,归一去重)优先,再按更新时间新→旧补足最近节点,总量 ≤80。
|
|
15
|
+
* 词法相关的判定复用 normalizeEntityName + tokenize,不 includes。
|
|
16
|
+
*/
|
|
17
|
+
export function selectContextNodes(records, nodes, limit = GRAPH_CONTEXT_NODE_LIMIT) {
|
|
18
|
+
if (nodes.length === 0 || limit <= 0)
|
|
19
|
+
return [];
|
|
20
|
+
// 批内记录的 token 集(小写已由 tokenize 统一)
|
|
21
|
+
const recordTokens = new Set();
|
|
22
|
+
const names = new Set();
|
|
23
|
+
for (const r of records) {
|
|
24
|
+
for (const t of tokenize(r.content))
|
|
25
|
+
recordTokens.add(t);
|
|
26
|
+
for (const t of tokenize(r.scene_name))
|
|
27
|
+
recordTokens.add(t);
|
|
28
|
+
for (const t of tokenize(r.type))
|
|
29
|
+
recordTokens.add(t);
|
|
30
|
+
}
|
|
31
|
+
const lexical = [];
|
|
32
|
+
const rest = [];
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
const keys = [node.name, ...node.aliases, ...(node.tags ?? [])].map(normalizeEntityName);
|
|
35
|
+
if (keys.some((k) => names.has(k)))
|
|
36
|
+
continue; // 已被更早节点覆盖的同名节点跳过
|
|
37
|
+
for (const k of keys)
|
|
38
|
+
names.add(k);
|
|
39
|
+
const nodeTokens = new Set();
|
|
40
|
+
for (const t of tokenize(node.name))
|
|
41
|
+
nodeTokens.add(t);
|
|
42
|
+
for (const t of node.aliases.flatMap((a) => tokenize(a)))
|
|
43
|
+
nodeTokens.add(t);
|
|
44
|
+
for (const t of (node.tags ?? []).flatMap((tag) => tokenize(tag)))
|
|
45
|
+
nodeTokens.add(t);
|
|
46
|
+
for (const t of tokenize(node.currentState))
|
|
47
|
+
nodeTokens.add(t);
|
|
48
|
+
const related = [...nodeTokens].some((t) => recordTokens.has(t));
|
|
49
|
+
(related ? lexical : rest).push(node);
|
|
50
|
+
}
|
|
51
|
+
// 词法相关优先(新→旧),其余按更新时间新→旧补足
|
|
52
|
+
const byRecency = (a, b) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.id < b.id ? -1 : 1;
|
|
53
|
+
lexical.sort(byRecency);
|
|
54
|
+
rest.sort(byRecency);
|
|
55
|
+
return [...lexical, ...rest].slice(0, limit);
|
|
56
|
+
}
|
|
57
|
+
/** 选择进入上下文的边:两端都入选节点的 active 边,按更新时间新→旧,≤120。 */
|
|
58
|
+
export function selectContextEdges(nodes, edges, limit = GRAPH_CONTEXT_EDGE_LIMIT) {
|
|
59
|
+
if (edges.length === 0 || limit <= 0)
|
|
60
|
+
return [];
|
|
61
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
62
|
+
return edges
|
|
63
|
+
.filter((e) => e.status === 'active' && ids.has(e.fromNodeId) && ids.has(e.toNodeId))
|
|
64
|
+
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
|
|
65
|
+
.slice(0, limit);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
|
|
69
|
+
* 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
|
|
70
|
+
* GraphStore 管),不向 runner 上抛。
|
|
71
|
+
*/
|
|
72
|
+
export async function runGraphProjection(ctx, cfg, graphs, logger) {
|
|
73
|
+
const claim = graphs.claimNext();
|
|
74
|
+
if (!claim)
|
|
75
|
+
return false;
|
|
76
|
+
const { job, records } = claim;
|
|
77
|
+
try {
|
|
78
|
+
const graph = graphs.loadGraph();
|
|
79
|
+
const ctxNodes = selectContextNodes(records, graph.nodes);
|
|
80
|
+
const ctxEdges = selectContextEdges(ctxNodes, graph.edges);
|
|
81
|
+
const raw = await callLLM(ctx, cfg, {
|
|
82
|
+
system: getGraphProjectionSystemPrompt(),
|
|
83
|
+
user: buildGraphProjectionPrompt({ records, nodes: ctxNodes, edges: ctxEdges }),
|
|
84
|
+
maxTokens: resolveLayerTokens(cfg, 'graph'),
|
|
85
|
+
layer: 'graph',
|
|
86
|
+
logger,
|
|
87
|
+
});
|
|
88
|
+
graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
logger.warn(`[memory] 图谱投影失败(job=${job.id},attempts=${job.attempts}): ${errDetail(err)}`);
|
|
92
|
+
graphs.fail(job.id, errDetail(err));
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/** 投影输出解析与形状归一:非对象/缺数组字段回空结构(校验侧再逐条把关)。 */
|
|
97
|
+
export function parseProjection(raw, logger) {
|
|
98
|
+
const parsed = parseJsonLogged(raw, '图谱投影', logger);
|
|
99
|
+
return {
|
|
100
|
+
reason: typeof parsed?.reason === 'string' ? parsed.reason : '',
|
|
101
|
+
nodes: Array.isArray(parsed?.nodes) ? parsed.nodes : [],
|
|
102
|
+
edges: Array.isArray(parsed?.edges) ? parsed.edges : [],
|
|
103
|
+
};
|
|
104
|
+
}
|