dsh-prime-memory 0.26.12 → 0.26.14
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 +34 -0
- package/dist/client.js +33 -5
- package/dist/contract.d.ts +47 -0
- package/dist/index.js +20 -2
- package/dist/pipeline/l1.js +3 -2
- package/dist/pipeline/runner.d.ts +8 -0
- package/dist/pipeline/runner.js +17 -0
- package/dist/projection/recall.d.ts +3 -1
- package/dist/projection/recall.js +11 -5
- package/dist/skills/crystallize.d.ts +17 -2
- package/dist/skills/crystallize.js +17 -6
- package/dist/skills/producer.d.ts +89 -0
- package/dist/skills/producer.js +274 -0
- package/dist/stats.d.ts +8 -1
- package/dist/stats.js +52 -3
- package/dist/store/l1.d.ts +7 -0
- package/dist/store/l1.js +9 -0
- package/dist/store/memory-md-parse.js +2 -1
- package/dist/store/skill-store.d.ts +21 -0
- package/dist/store/skill-store.js +40 -1
- package/dist/store/sqlite.d.ts +7 -0
- package/dist/store/sqlite.js +19 -1
- package/dist/util/priority.d.ts +22 -0
- package/dist/util/priority.js +36 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# 更新日志(Changelog)
|
|
2
2
|
|
|
3
|
+
## [0.26.14] — 2026-10-28
|
|
4
|
+
|
|
5
|
+
> 修复 issue #15:「技能结晶」自上线以来**根本没有生产者**——`detectCandidates` 与 `SkillStore.create()` 在全仓**只有测试调用点**,所以 `skills.json` 永远不会被生成,看板恒为 0。用户打开 `skillsEnabled` 开关后拿到的是一个空盒子,而且不报错、不提示,只能翻源码才知道功能没接上。
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **接入生产者(新增 `src/skills/producer.ts`)**,两条路径共用一套分组语义:
|
|
10
|
+
- **稳态增量**:挂在 `runner.runTurn` 的 L2 攒批块之后,吃本轮新出的 `work_method` 记录 + 本轮 `sessionId`。0 全表扫描、0 LLM、0 token,不新增 timer。
|
|
11
|
+
- **一次性回灌**(`backfill`):分页 `allLite` 扫存量→`getByIds` 取正文→从 `metadata.dsh_source_anchors` 复原会话归属,让老库不必再跨 N 个会话才见第一条技能。**缺锚点的记录直接跳过并计数,绝不伪造会话**(那会虚增 distinct-session 骗过晋升门)。
|
|
12
|
+
- 两条路径共用 `crystallize.ts` 导出的 `groupKey`/`fnv1a`(新增单测直接断言两路算出的候选 id 逐字相同)——否则同一组会结晶出两个候选,幂等崩。
|
|
13
|
+
- **`sessionCount` 只数 `evidence` 行**(不看 `evidenceRecordIds`)——所以上线版只 `create()` 不投证据的话,即使 `allowPromote` 打开也永不晋升。生产者逐记录 `appendEvidence`,去重键是 **(skill, session, record) 三元组**而非二元组:一条历史记录可映射到多个会话,按二元组去重会抹掉第 2、3 个会话。
|
|
14
|
+
- **失败隔离**:技能写库异常全部内化为 `summary.errors` + `logger.warn`,**不得抛穿蒸馏轮次**;`MemoryStores.skills` 为可选字段,未装配时整条路径跳过(既有 186 个测试文件的 store 桩都不带该字段,全绿即零回归证据)。
|
|
15
|
+
- **诚实化(issue 建议 2/3)**:
|
|
16
|
+
- 面板空态拆三态(旧宿主无 `producer` 字段 / 池里没原料 / 有原料但未达阈),不再用一句「暂无技能」糊过去;未启用文案去掉「重复工作流会固化为技能候选」这句当时做不到的承诺。
|
|
17
|
+
- `skillsEnabled=true` 但 `skills.json` 从未生成时,启动日志留一条可 grep 的 warning。
|
|
18
|
+
- 新增 `dsh-memory/skills-detect` RPC(需「高权限模式」,与 `skills-action` 同门)与面板「立即检测历史」按钮;`skills-list` 附 `producer` 健康态。
|
|
19
|
+
- **`minEvidence` 首次真实生效**:此前三处(store / RPC / 检测)都没消费它。现在自动晋升需 distinct-session ≥ `minSessions` **且** 证据条数 ≥ `minEvidence`;人工晋升仍只卡会话数(人审是人的判断)。
|
|
20
|
+
- **不变量保留**:`body`(人编辑区)不被机器侧覆盖,二次检测只重写 `steps`;技能仍不是 memory `type`(不碰 `ADD_TYPES` 与蒸馏 prompt 词表);不给 `l1_records` 加列。
|
|
21
|
+
- **默认行为不变**:`skills.enabled` 与 `allowPromote` **仍默认关**。本版只保证「开了开关就有生产者」;候选需人工审阅晋升(自动晋升需显式开启)。
|
|
22
|
+
- **已知局限(另立轨 3 issue)**:分组键是精确 `(hall, scene_name)` + 字面去重,语义等价但措辞不同的方法会散到不同组(假阴性)。要修得靠 LLM 判同/命名,会破坏轨 2 的可解释性与 `skx-` 幂等 id 保证,因此独立演进。
|
|
23
|
+
- 测试:新增 `tests/skill-producer.test.ts` 15 例(磁盘真落盘 + 证据非空、幂等、双阈两档、body 不被覆盖、backfill 分页/缺锚点/与增量不重复、失败隔离);全量 **1858 例**/186 文件绿,三配置 typecheck 与 lint 零新增错。
|
|
24
|
+
|
|
25
|
+
## [0.26.13] — 2026-10-28
|
|
26
|
+
|
|
27
|
+
> 修复 issue #19:单条 L1 priority 越界(如 LLM 回 `-1`)经无夹取写点落库后,`memoryRecall` 投影的 `viewSchema` 严格校验(判非负整数)抛错 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据打死整个会话历史**。
|
|
28
|
+
|
|
29
|
+
### 修复
|
|
30
|
+
|
|
31
|
+
- **双防线**:
|
|
32
|
+
- **写侧夹取(防新增)**:新建 `src/util/priority.ts` 单源 `clampPriority`(非有限→回落缺省、小数 `Math.round`、夹到整数 `[0,100]`),L1 抽取/合并构造(`pipeline/l1.ts` 两处)、upsert 绑定层(`store/sqlite.ts`,保留历史缺省 50)、MD 导入解析(`store/memory-md-parse.ts`)统一走同一夹取。
|
|
33
|
+
- **读侧兜底(自愈存量)**:投影 `makeView` 遇脏 priority 就地夹取并 `warn`(带 record id + 原值),绝不让单条脏字段打死整会话。因升级必重启宿主、`viewMemo`(进程内 WeakMap)随之清空,脏行读时被夹取 → **存量脏数据无需手动改库、无需迁移**。
|
|
34
|
+
- 保留 `viewSchema` 严格校验(纵深防御仍在,只是喂进去的已是干净值);合法 0–100 整数读写逐字不变;`slots.ts` 的同名私有 `clampPriority`(floor/50,memorySlots 另一条链)不受影响。
|
|
35
|
+
- 本版叠加在 0.26.12(DSH 0.2.1-rc.4 适配)之上,`latest` 与 `dsh-0.2.1-rc.4` dist-tag 同指本版。
|
|
36
|
+
|
|
3
37
|
## [0.26.12] — 2026-10-28
|
|
4
38
|
|
|
5
39
|
> 0.26.12: host enumeration addendum for DSH 0.2.1-rc.4 (declaration-only bump; contract plane zero diff). `engines.dsh`/`dsh.plugin.json`/README 同串补 `0.2.1-rc.4` 枚举行(19 档)。
|
package/dist/client.js
CHANGED
|
@@ -37020,10 +37020,19 @@ var __create = Object.create;
|
|
|
37020
37020
|
return r && r.error && r.error.message ? r.error.message : "RPC 错误";
|
|
37021
37021
|
}
|
|
37022
37022
|
var TITLES = { candidate: "候选", active: "活跃", archived: "已归档" };
|
|
37023
|
+
function emptyHint(data) {
|
|
37024
|
+
const p = data.producer;
|
|
37025
|
+
if (!p) return '暂无技能。技能从重复出现的"工作方法"记忆结晶而来。';
|
|
37026
|
+
if (p.workMethodCount === 0) {
|
|
37027
|
+
return "暂无技能:记忆池里还没有「工作方法」类记录。技能是从重复出现的工作方法结晶来的,请先到概览页确认蒸馏在正常产出记忆。";
|
|
37028
|
+
}
|
|
37029
|
+
return `暂无技能:池中已有 ${p.workMethodCount} 条「工作方法」,但还没重复到成候选的程度(需跨 ≥${p.minSessions} 个不同会话)。存量可点「立即检测历史」回灌。`;
|
|
37030
|
+
}
|
|
37023
37031
|
function SkillsTab(props) {
|
|
37024
37032
|
const rpc = props.rpc;
|
|
37025
37033
|
const [data, setData] = (0, import_react4.useState)(null);
|
|
37026
37034
|
const [error, setError] = (0, import_react4.useState)(null);
|
|
37035
|
+
const [notice, setNotice] = (0, import_react4.useState)(null);
|
|
37027
37036
|
const [busy, setBusy] = (0, import_react4.useState)(false);
|
|
37028
37037
|
const load = (0, import_react4.useCallback)(() => {
|
|
37029
37038
|
rpc("dsh-memory/skills-list", {}).then((r) => {
|
|
@@ -37046,24 +37055,43 @@ var __create = Object.create;
|
|
|
37046
37055
|
} else setError(errMsg2(r));
|
|
37047
37056
|
}).catch((e) => setError(String(e?.message || e))).finally(() => setBusy(false));
|
|
37048
37057
|
};
|
|
37058
|
+
const detect = () => {
|
|
37059
|
+
if (busy) return;
|
|
37060
|
+
setBusy(true);
|
|
37061
|
+
setNotice(null);
|
|
37062
|
+
rpc("dsh-memory/skills-detect", {}).then((r) => {
|
|
37063
|
+
if (r && r.ok) {
|
|
37064
|
+
setError(r.value.error ? `部分失败:${r.value.error}` : null);
|
|
37065
|
+
setNotice(r.value.notice || null);
|
|
37066
|
+
load();
|
|
37067
|
+
} else setError(errMsg2(r));
|
|
37068
|
+
}).catch((e) => setError(String(e?.message || e))).finally(() => setBusy(false));
|
|
37069
|
+
};
|
|
37049
37070
|
if (data && !data.enabled) {
|
|
37050
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: S.intro, children: "技能结晶(程序性记忆)未启用。到「概览」页开启「技能」开关(或部署配置 skills.enabled
|
|
37071
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: S.intro, children: "技能结晶(程序性记忆)未启用。到「概览」页开启「技能」开关(或部署配置 skills.enabled):开启后,跨会话重复出现的「工作方法」记忆会累积为技能候选,在此审阅并手动晋升。" });
|
|
37051
37072
|
}
|
|
37052
|
-
const mutateHint = data && !data.memoryMutate ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.hint, children: "
|
|
37073
|
+
const mutateHint = data && !data.memoryMutate ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.hint, children: "提示:晋升/归档/固定/检测历史需在面板开启「高权限模式」。" }) : null;
|
|
37074
|
+
const promotable = data && data.producer ? data.skills.filter((s) => s.status === "candidate" && s.sessionCount >= data.producer.minSessions).length : 0;
|
|
37053
37075
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { children: [
|
|
37054
37076
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { ...S.flexRow, marginBottom: 10 }, children: [
|
|
37055
37077
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: S.muted, children: data ? `${data.skills.length} 个技能 · 自动晋升${data.allowPromote ? "开" : "关(人工审阅)"} · 需证据会话达阈` : "加载中…" }),
|
|
37056
37078
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.grow }),
|
|
37057
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NButton, { onClick:
|
|
37079
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NButton, { onClick: detect, disabled: busy || !data?.memoryMutate, children: "立即检测历史" }),
|
|
37080
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NButton, { onClick: load, style: { marginLeft: 6 }, children: "刷新" })
|
|
37058
37081
|
] }),
|
|
37059
37082
|
mutateHint,
|
|
37083
|
+
notice ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.hint, children: notice }) : null,
|
|
37060
37084
|
error ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.error, children: error }) : null,
|
|
37061
|
-
data &&
|
|
37085
|
+
data && promotable > 0 && !data.allowPromote ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: S.hint, children: [
|
|
37086
|
+
promotable,
|
|
37087
|
+
" 个候选的证据会话数已达阈,但自动晋升关闭——确认无误后可手动「晋升」。"
|
|
37088
|
+
] }) : null,
|
|
37089
|
+
data && data.skills.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: S.intro, children: emptyHint(data) }) : null,
|
|
37062
37090
|
(data?.skills || []).map((s) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "dsh-mem-card", style: S.card, children: [
|
|
37063
37091
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: S.cardHead, children: [
|
|
37064
37092
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("strong", { children: s.title }),
|
|
37065
37093
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "dsh-mem-tag", style: { marginLeft: 6 }, children: TITLES[s.status] }),
|
|
37066
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: S.muted, children: ` · 档:${s.tier}${s.pinned ? " · 已固定" : ""} · 覆盖 ${s.sessionCount}
|
|
37094
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: S.muted, children: ` · 档:${s.tier}${s.pinned ? " · 已固定" : ""} · 覆盖 ${s.sessionCount} 会话 / 证据 ${s.evidenceCount} 条` }),
|
|
37067
37095
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: S.grow })
|
|
37068
37096
|
] }),
|
|
37069
37097
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }, children: [
|
package/dist/contract.d.ts
CHANGED
|
@@ -1741,14 +1741,59 @@ export interface SkillView {
|
|
|
1741
1741
|
tier: 'full' | 'excerpt' | 'hint';
|
|
1742
1742
|
pinned: boolean;
|
|
1743
1743
|
sessionCount: number;
|
|
1744
|
+
/** 证据总条数(与 `sessionCount` 的 distinct 语义不同;面板用它算 minEvidence 进度)。 */
|
|
1745
|
+
evidenceCount: number;
|
|
1744
1746
|
updatedAt: string;
|
|
1745
1747
|
}
|
|
1748
|
+
/**
|
|
1749
|
+
* 生产者健康态(0.26.14+)——面板区分“空盒子”与“已接线但未达阈”的唯一依据。
|
|
1750
|
+
*
|
|
1751
|
+
* 旧版宿主不返此字段,面板按 `producer === undefined` 回到旧文案(不谎报已接线)。
|
|
1752
|
+
*/
|
|
1753
|
+
export interface SkillProducerView {
|
|
1754
|
+
/** 本版起恒 true;字段保留是为了让面板能识别旧版(无生产者)现场。 */
|
|
1755
|
+
wired: boolean;
|
|
1756
|
+
/** 记忆池里的 work_method 记录数(为 0 = 蒸馏还没产出可结晶的原料)。 */
|
|
1757
|
+
workMethodCount: number;
|
|
1758
|
+
/** 已结晶的候选数(含 active/archived 之外的 candidate)。 */
|
|
1759
|
+
candidateCount: number;
|
|
1760
|
+
/** 晋升需 distinct session 数(来自 cfg.skills.minSessions)。 */
|
|
1761
|
+
minSessions: number;
|
|
1762
|
+
/** 自动晋升需证据条数(来自 cfg.skills.minEvidence;仅约束自动路径)。 */
|
|
1763
|
+
minEvidence: number;
|
|
1764
|
+
lastRoundAt?: string;
|
|
1765
|
+
backfilledAt?: string;
|
|
1766
|
+
}
|
|
1746
1767
|
export interface SkillsListResponse {
|
|
1747
1768
|
enabled: boolean;
|
|
1748
1769
|
/** 自动晋升总闸(allowPromote);关时 promote 动作会被拒。 */
|
|
1749
1770
|
allowPromote: boolean;
|
|
1750
1771
|
memoryMutate: boolean;
|
|
1751
1772
|
skills: SkillView[];
|
|
1773
|
+
/** 生产者健康态(0.26.14+;旧宿主缺省)。 */
|
|
1774
|
+
producer?: SkillProducerView;
|
|
1775
|
+
}
|
|
1776
|
+
/** dsh-memory/skills-detect:手动回灌历史(一次性扫描存量 work_method)。 */
|
|
1777
|
+
export interface SkillsDetectRequest {
|
|
1778
|
+
/** 扫描上限(缺省 20000);大库可调小先试水。 */
|
|
1779
|
+
maxRecords?: number;
|
|
1780
|
+
}
|
|
1781
|
+
export interface SkillsDetectResponse {
|
|
1782
|
+
ok: boolean;
|
|
1783
|
+
/** 扫过的 L1 记录总数。 */
|
|
1784
|
+
scanned: number;
|
|
1785
|
+
/** 其中可用的 work_method 条数。 */
|
|
1786
|
+
methods: number;
|
|
1787
|
+
created: number;
|
|
1788
|
+
appended: number;
|
|
1789
|
+
promoted: number;
|
|
1790
|
+
/** 因缺 `dsh_source_anchors` 而无法归属会话的记录数(不猜 provenance)。 */
|
|
1791
|
+
skippedNoAnchor: number;
|
|
1792
|
+
pages: number;
|
|
1793
|
+
/** 是否因扫描上限被截断。 */
|
|
1794
|
+
truncated: boolean;
|
|
1795
|
+
notice: string;
|
|
1796
|
+
error?: string;
|
|
1752
1797
|
}
|
|
1753
1798
|
export interface SkillsActionRequest {
|
|
1754
1799
|
id: string;
|
|
@@ -1940,6 +1985,7 @@ export interface DshMemoryRequestMap {
|
|
|
1940
1985
|
'dsh-memory/memory-sources-retire': MemorySourcesRetireRequest;
|
|
1941
1986
|
'dsh-memory/memport-scan': MemportScanRequest;
|
|
1942
1987
|
'dsh-memory/skills-list': Record<string, never>;
|
|
1988
|
+
'dsh-memory/skills-detect': SkillsDetectRequest;
|
|
1943
1989
|
'dsh-memory/session-recalled': SessionRecalledRequest;
|
|
1944
1990
|
'dsh-memory/recall-feedback': RecallFeedbackRequest;
|
|
1945
1991
|
'dsh-memory/workspace-relink': WorkspaceRelinkRequest;
|
|
@@ -2007,6 +2053,7 @@ export interface DshMemoryResponseMap {
|
|
|
2007
2053
|
'dsh-memory/memory-sources-retire': MemorySourcesRetireResponse;
|
|
2008
2054
|
'dsh-memory/memport-scan': MemportScanResponse;
|
|
2009
2055
|
'dsh-memory/skills-list': SkillsListResponse;
|
|
2056
|
+
'dsh-memory/skills-detect': SkillsDetectResponse;
|
|
2010
2057
|
'dsh-memory/session-recalled': SessionRecalledResponse;
|
|
2011
2058
|
'dsh-memory/recall-feedback': RecallFeedbackResponse;
|
|
2012
2059
|
'dsh-memory/workspace-relink': WorkspaceRelinkResponse;
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ import { registerObsoleteNotice } from './hooks/obsolete-notice.js';
|
|
|
42
42
|
import { RoomRegistryStore } from './store/rooms-registry.js';
|
|
43
43
|
import { MemorySourceStore } from './store/memory-sources.js';
|
|
44
44
|
import { SkillStore } from './store/skill-store.js';
|
|
45
|
+
import { createSkillProducer } from './skills/producer.js';
|
|
45
46
|
import { registerSkillRecall } from './hooks/skill-recall.js';
|
|
46
47
|
import { registerSkillTools } from './tools/skills.js';
|
|
47
48
|
import { RecallUsageStore } from './store/recall-usage.js';
|
|
@@ -209,6 +210,8 @@ export async function apply(ctx, config) {
|
|
|
209
210
|
const stores = {
|
|
210
211
|
usage: undefined, // 装配于下方(依赖 dataDir 就绪)
|
|
211
212
|
routeHealth: undefined, // R9 路由健康学习,装配于下方
|
|
213
|
+
// 轨2 技能结晶生产者(issue #15 的缺失环节):装配于下方(依赖 skillStore 就绪)。
|
|
214
|
+
skills: undefined,
|
|
212
215
|
// DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
|
|
213
216
|
// 硬依赖无本地回退(l0 表退役);trustedHosts 惰性读取(webStartup 可能晚到)。
|
|
214
217
|
drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
|
|
@@ -425,6 +428,19 @@ export async function apply(ctx, config) {
|
|
|
425
428
|
// 技能存储(轨2;sidecar 无条件加载,但工具/注入均由 cfg.skills.enabled 默认关自 no-op)
|
|
426
429
|
const skillStore = new SkillStore(dataDir, logger);
|
|
427
430
|
await skillStore.init();
|
|
431
|
+
// 技能结晶生产者:稳态由 runner 在每轮 L2 之后喂(`stores.skills`),历史存量走
|
|
432
|
+
// 面板「立即检测历史」/`skills-detect` RPC。cfg 用函数取:面板改阈值下一轮即生效。
|
|
433
|
+
stores.skills = createSkillProducer({
|
|
434
|
+
skills: skillStore,
|
|
435
|
+
l1: stores.l1,
|
|
436
|
+
cfg: () => effectiveCfg(config, live).skills,
|
|
437
|
+
logger,
|
|
438
|
+
});
|
|
439
|
+
// 诚实化(issue #15 建议 3):开关已开但 sidecar 从来没被写过,就是“无生产者”的
|
|
440
|
+
// 历史现场。以前这里静默为 0、界面看不出异常,用户只能猜是自己操作错了。
|
|
441
|
+
if (skillStore.wasAbsentAtBoot() && (config.skills.enabled || live.get().skillsEnabled)) {
|
|
442
|
+
logger.warn('[memory] 技能开关已开但 skills.json 尚不存在:将在首轮结晶后生成(存量可在技能页点「立即检测历史」回灌)');
|
|
443
|
+
}
|
|
428
444
|
const ruminate = storageOk && !db.isDegraded() && ruminateFile
|
|
429
445
|
? new RuminateController(ctx, config, runner, stores, logger, live, ruminateFile, roomRegistry)
|
|
430
446
|
: undefined;
|
|
@@ -497,7 +513,7 @@ export async function apply(ctx, config) {
|
|
|
497
513
|
// memoryRecall 投影:本会话“已注入且未被压缩覆盖”的召回集(会话感知,init 取 header.id),
|
|
498
514
|
// 与常驻槽位是两条轴:那个是手动 pin 的硬注入约定,这个是非常驻的相关性浮现结果。
|
|
499
515
|
// 带上反馈面:条目要显示人已经打过的那一笔(rating)。
|
|
500
|
-
registerRecallProjection(ctx, stores.recalled, stores.l1, recallFeedback);
|
|
516
|
+
registerRecallProjection(ctx, stores.recalled, stores.l1, recallFeedback, logger);
|
|
501
517
|
// 人打 -1 声明过时后,下一轮向模型一次性播报“这条已退场、别再依据它作答”。
|
|
502
518
|
registerObsoleteNotice(ctx, config, obsoleteNotices, logger);
|
|
503
519
|
void slotRecall;
|
|
@@ -514,7 +530,9 @@ export async function apply(ctx, config) {
|
|
|
514
530
|
runnerView: (sid, mode) => runner.sessionView(sid, mode),
|
|
515
531
|
l0Count: (sid) => stores.l0.countBySession(sid),
|
|
516
532
|
capabilities: () => db.getCapabilities(),
|
|
517
|
-
}, ruminate, storageOk ? roomRegistry : undefined, storageOk ? memorySources : undefined, skillStore
|
|
533
|
+
}, ruminate, storageOk ? roomRegistry : undefined, storageOk ? memorySources : undefined, skillStore,
|
|
534
|
+
// 技能生产者:面板「立即检测历史」与 producer 健康态的数据源。
|
|
535
|
+
stores.skills);
|
|
518
536
|
// bench 控制服务(config.benchControl 门控,默认关):仅基准/调试部署注册,
|
|
519
537
|
// 供同进程的 bench-runner lifecycle 赛道触发 rebuild / 设置会话档位
|
|
520
538
|
// (宿主侧 RPC 无 call(),见 bench-control.ts)
|
package/dist/pipeline/l1.js
CHANGED
|
@@ -14,6 +14,7 @@ import { buildConflictPair, conflictRejectId, DEFER_MAX, normalizeClaimKey, norm
|
|
|
14
14
|
import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
|
|
15
15
|
import { normTags, normUsageScenario, readCognitiveHall, readWing } from '../metadata-validators.js';
|
|
16
16
|
import { acceptTitle } from '../util/md-name.js';
|
|
17
|
+
import { clampPriority } from '../util/priority.js';
|
|
17
18
|
import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
|
|
18
19
|
import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
|
|
19
20
|
import { stampValidity, validToSourceOf } from '../store/validity.js';
|
|
@@ -196,7 +197,7 @@ export function toStoreRecord(m, now, ts, anchorMap) {
|
|
|
196
197
|
id: m.record_id,
|
|
197
198
|
content: m.content,
|
|
198
199
|
type: m.type,
|
|
199
|
-
priority:
|
|
200
|
+
priority: clampPriority(m.priority),
|
|
200
201
|
scene_name: m.scene_name,
|
|
201
202
|
timestamps: [Number.isNaN(ts) ? now : ts],
|
|
202
203
|
createdAt: now,
|
|
@@ -714,7 +715,7 @@ routeHealth) {
|
|
|
714
715
|
id: m.record_id,
|
|
715
716
|
content: mergedContent,
|
|
716
717
|
type: decision.merged_type || m.type,
|
|
717
|
-
priority:
|
|
718
|
+
priority: clampPriority(decision.merged_priority ?? m.priority),
|
|
718
719
|
scene_name: m.scene_name,
|
|
719
720
|
timestamps: Array.from(new Set(mergedTs)).sort((a, b) => a - b),
|
|
720
721
|
createdAt: now,
|
|
@@ -24,6 +24,7 @@ import type { StateStore } from '../store/state.js';
|
|
|
24
24
|
import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
|
|
25
25
|
import type { FamilyStates } from './l1.js';
|
|
26
26
|
import type { RouteHealthStore } from '../store/route-health.js';
|
|
27
|
+
import type { SkillProducer } from '../skills/producer.js';
|
|
27
28
|
export interface MemoryStores {
|
|
28
29
|
/**
|
|
29
30
|
* DRSC 客户端(T8.4 读切):蒸馏背景窗的历史供给面(op=session&tail&redact=1)。
|
|
@@ -48,6 +49,13 @@ export interface MemoryStores {
|
|
|
48
49
|
};
|
|
49
50
|
/** R9 路由健康学习(可选):未传则 runExtraction 不启用自适应块长学习。 */
|
|
50
51
|
routeHealth?: RouteHealthStore;
|
|
52
|
+
/**
|
|
53
|
+
* 轨2 技能结晶的生产者(可选):未装配时蒸馏轮次完全不做结晶,行为与改动前一致。
|
|
54
|
+
*
|
|
55
|
+
* 挂在 L2 之后而不是每轮开头:复用既有攒批节奏、不新增 timer,且失败只记日志
|
|
56
|
+
* (技能结晶不该把蒸馏轮次带崩)。
|
|
57
|
+
*/
|
|
58
|
+
skills?: SkillProducer;
|
|
51
59
|
}
|
|
52
60
|
/** 管线任务(优先级调度:live > graph > rebuild)。 */
|
|
53
61
|
export interface PipelineTask {
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -759,6 +759,23 @@ export class MemoryRunner {
|
|
|
759
759
|
}
|
|
760
760
|
}
|
|
761
761
|
}
|
|
762
|
+
// ── 轨2 技能结晶:把本轮新出的 work_method 喂给生产者 ──
|
|
763
|
+
// 位置选在 L2 之后:不抢既有层链、`newRecords` 已在手且天然属于本轮 sessionId
|
|
764
|
+
// (L1 记录本身不存会话,这是 distinct-session 唯一可靠的来源)。
|
|
765
|
+
// 独立 try:技能写库失败绝不能影响 L1/L2/L3 落库与图谱泵。
|
|
766
|
+
if (distillOn && this.stores.skills !== undefined && newRecords.length > 0) {
|
|
767
|
+
try {
|
|
768
|
+
const s = await this.stores.skills.ingestRound(newRecords, sessionId, `run-${turnStart}`);
|
|
769
|
+
if (s.errors.length > 0)
|
|
770
|
+
this.logger.warn(`[memory] 技能结晶部分失败(session=${sessionId}): ${s.errors.join(' | ')}`);
|
|
771
|
+
else if (s.candidatesTouched > 0) {
|
|
772
|
+
this.logger.debug?.(`[memory] 技能结晶完成(session=${sessionId},触及 ${s.candidatesTouched} 候选/新建 ${s.created}/证据 +${s.appended})`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
this.logger.warn(`[memory] 技能结晶失败(不影响蒸馏): ${errDetail(err)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
762
779
|
if (cfg.l3.enabled && distillOn) {
|
|
763
780
|
for (const family of ['chat', 'work']) {
|
|
764
781
|
const tL3 = Date.now();
|
|
@@ -116,5 +116,7 @@ export interface RecallFeedbackFace {
|
|
|
116
116
|
* apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
|
|
117
117
|
* @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
|
|
118
118
|
*/
|
|
119
|
-
export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace
|
|
119
|
+
export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace, log?: {
|
|
120
|
+
warn(message: string): void;
|
|
121
|
+
}): void;
|
|
120
122
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRetired } from '../store/supersede.js';
|
|
2
2
|
import { stateOfRecord } from '../store/validity.js';
|
|
3
|
+
import { clampPriority, isDirtyPriority } from '../util/priority.js';
|
|
3
4
|
export const MEMORY_RECALL_KEY = 'memoryRecall';
|
|
4
5
|
/** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
|
|
5
6
|
export const RECALL_VIEW_CAP = 20;
|
|
@@ -141,7 +142,7 @@ function sameIds(a, b) {
|
|
|
141
142
|
}
|
|
142
143
|
/** view 引用稳定:同一 state 恒返回同一对象(registry 按 Object.is 判发布)。 */
|
|
143
144
|
const viewMemo = new WeakMap();
|
|
144
|
-
function makeView(state, l1, feedback) {
|
|
145
|
+
function makeView(state, l1, feedback, log) {
|
|
145
146
|
const memoized = viewMemo.get(state);
|
|
146
147
|
if (memoized !== undefined)
|
|
147
148
|
return memoized;
|
|
@@ -163,11 +164,16 @@ function makeView(state, l1, feedback) {
|
|
|
163
164
|
// 未退场行才会真判过时两档(退场行由 retired 键表达,validity 不再叠加)。
|
|
164
165
|
const vState = isRetired(r) ? undefined : stateOfRecord(r, Date.now());
|
|
165
166
|
const showValidity = vState === 'expired' || vState === 'expired_suspect';
|
|
167
|
+
// #19 读侧兜底:脏 priority(负/超界/非整数/非有限)就地夹取,绝不让 viewSchema
|
|
168
|
+
// 因单条脏字段抛错打死整会话;命中时 warn 留痕(静默夹取会掩盖上游 LLM 异常)。
|
|
169
|
+
const rawPriority = r.priority;
|
|
170
|
+
if (isDirtyPriority(rawPriority))
|
|
171
|
+
log?.warn(`[memory][recall] 脏 priority 回落 record=${r.id} raw=${String(rawPriority)}`);
|
|
166
172
|
items.push({
|
|
167
173
|
id: r.id,
|
|
168
174
|
type: r.type,
|
|
169
175
|
title: r.name || firstLine(r.content),
|
|
170
|
-
priority:
|
|
176
|
+
priority: clampPriority(rawPriority),
|
|
171
177
|
body: clampBody(r.content),
|
|
172
178
|
...(scenario !== undefined && scenario.length > 0 ? { scenario } : {}),
|
|
173
179
|
...(typeof rating === 'number' ? { rating } : {}),
|
|
@@ -196,7 +202,7 @@ function sessionIdOf(header) {
|
|
|
196
202
|
* apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
|
|
197
203
|
* @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
|
|
198
204
|
*/
|
|
199
|
-
export function registerRecallProjection(ctx, store, l1, feedback) {
|
|
205
|
+
export function registerRecallProjection(ctx, store, l1, feedback, log) {
|
|
200
206
|
ctx.inject(['sessionProjections'], (injected) => {
|
|
201
207
|
const registry = injected.sessionProjections;
|
|
202
208
|
if (registry === undefined || typeof registry.register !== 'function')
|
|
@@ -208,9 +214,9 @@ export function registerRecallProjection(ctx, store, l1, feedback) {
|
|
|
208
214
|
stateSchema,
|
|
209
215
|
init: (header) => buildState(sessionIdOf(header), store, fbRev()),
|
|
210
216
|
apply: (state) => buildState(state.sessionId, store, fbRev(), state),
|
|
211
|
-
wire: { viewSchema, view: (state) => makeView(state, l1, feedback) },
|
|
217
|
+
wire: { viewSchema, view: (state) => makeView(state, l1, feedback, log) },
|
|
212
218
|
schema: viewSchema,
|
|
213
|
-
view: (state) => makeView(state, l1, feedback),
|
|
219
|
+
view: (state) => makeView(state, l1, feedback, log),
|
|
214
220
|
stateVersion: 0,
|
|
215
221
|
};
|
|
216
222
|
registry.register(definition);
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* 从 `work_method` 记忆**派生技能候选**——技能不是 memory `type`(不碰 ADD_TYPES/prompt 词表,
|
|
5
5
|
* S1/S2/S3 不触发),而是对"跨会话重复出现的工作方法"的聚合投影(对齐 ADR-0007 可重建投影)。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* (接入 runner
|
|
7
|
+
* 本轮定位:**只提供确定性检测函数**,不自动挂进 runner 每轮热路径
|
|
8
|
+
* (接入 runner 会给每轮蒸馏加活儿,且需与攒批/层链协调)。
|
|
9
|
+
* 生产者在 `producer.ts`:稳态挂 L2 攒批轮次(`ingestRound`)+ 一次性历史回灌
|
|
10
|
+
* (`backfill`),两者共用本文件的 `groupKey`/`fnv1a` 以保证候选 id 同源。
|
|
9
11
|
* 检测是纯规则:按 (hall, scene) 分组,够 distinct-session 且够条数才成候选;
|
|
10
12
|
* 候选默认 `status:'candidate'`,晋升仍由 SkillStore.tryPromote(且 cfg.skills.allowPromote
|
|
11
13
|
* 默认关)单独把关——检测与晋升解耦,和 conflictFreeze"不自动裁决"同哲学。
|
|
@@ -34,6 +36,19 @@ export interface DetectOptions {
|
|
|
34
36
|
/** 每候选最多收录的步骤数(防单组爆炸)。 */
|
|
35
37
|
maxSteps?: number;
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* 分组键:hall + scene(缺省归 'general');决定"同一类工作流"的判定粒度。
|
|
41
|
+
*
|
|
42
|
+
* 导出给 `producer.ts` 复用:增量路径与 backfill 路径必须算出**同一个** key,
|
|
43
|
+
* 否则同一组会结晶出两个不同 id 的候选,幂等直接崩。改这里等于改存量技能 id。
|
|
44
|
+
*/
|
|
45
|
+
export declare function groupKey(r: MethodRecord): string;
|
|
46
|
+
/**
|
|
47
|
+
* 稳定短哈希(FNV-1a 32bit,零依赖、确定性),用于派生候选 id。
|
|
48
|
+
*
|
|
49
|
+
* 同 `groupKey`:单点导出供 producer 共用,禁止另写一份。
|
|
50
|
+
*/
|
|
51
|
+
export declare function fnv1a(s: string): string;
|
|
37
52
|
/**
|
|
38
53
|
* 检测候选:按 groupKey 聚合 work_method 记录,distinct-session ≥ minSessions 且
|
|
39
54
|
* 去重后内容条数 ≥ minEvidence 的组,产出一条 candidate 提案(按 groupKey 排序,确定性)。
|
|
@@ -4,18 +4,29 @@
|
|
|
4
4
|
* 从 `work_method` 记忆**派生技能候选**——技能不是 memory `type`(不碰 ADD_TYPES/prompt 词表,
|
|
5
5
|
* S1/S2/S3 不触发),而是对"跨会话重复出现的工作方法"的聚合投影(对齐 ADR-0007 可重建投影)。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* (接入 runner
|
|
7
|
+
* 本轮定位:**只提供确定性检测函数**,不自动挂进 runner 每轮热路径
|
|
8
|
+
* (接入 runner 会给每轮蒸馏加活儿,且需与攒批/层链协调)。
|
|
9
|
+
* 生产者在 `producer.ts`:稳态挂 L2 攒批轮次(`ingestRound`)+ 一次性历史回灌
|
|
10
|
+
* (`backfill`),两者共用本文件的 `groupKey`/`fnv1a` 以保证候选 id 同源。
|
|
9
11
|
* 检测是纯规则:按 (hall, scene) 分组,够 distinct-session 且够条数才成候选;
|
|
10
12
|
* 候选默认 `status:'candidate'`,晋升仍由 SkillStore.tryPromote(且 cfg.skills.allowPromote
|
|
11
13
|
* 默认关)单独把关——检测与晋升解耦,和 conflictFreeze"不自动裁决"同哲学。
|
|
12
14
|
*/
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* 分组键:hall + scene(缺省归 'general');决定"同一类工作流"的判定粒度。
|
|
17
|
+
*
|
|
18
|
+
* 导出给 `producer.ts` 复用:增量路径与 backfill 路径必须算出**同一个** key,
|
|
19
|
+
* 否则同一组会结晶出两个不同 id 的候选,幂等直接崩。改这里等于改存量技能 id。
|
|
20
|
+
*/
|
|
21
|
+
export function groupKey(r) {
|
|
15
22
|
return `${r.hall ?? 'general'}\u0000${r.sceneName ?? ''}`;
|
|
16
23
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
24
|
+
/**
|
|
25
|
+
* 稳定短哈希(FNV-1a 32bit,零依赖、确定性),用于派生候选 id。
|
|
26
|
+
*
|
|
27
|
+
* 同 `groupKey`:单点导出供 producer 共用,禁止另写一份。
|
|
28
|
+
*/
|
|
29
|
+
export function fnv1a(s) {
|
|
19
30
|
let h = 0x811c9dc5;
|
|
20
31
|
for (let i = 0; i < s.length; i++) {
|
|
21
32
|
h ^= s.charCodeAt(i);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能结晶的生产者(issue #15 的缺失环节)。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要这个文件**:`crystallize.detectCandidates` 自 0.26.x 起就存在,但全仓
|
|
5
|
+
* 只有测试调用它,`SkillStore.create()` 同样零生产调用点 ⇒ `skills.json` 永远不会被
|
|
6
|
+
* 写入,看板恒为 0。本文件补的就是生产者。
|
|
7
|
+
*
|
|
8
|
+
* **两条路径,一套语义**:
|
|
9
|
+
* - `ingestRound`(稳态):挂 `runner.runTurn` 的 L2 攒批轮次之后,吃本轮新记录 +
|
|
10
|
+
* 本轮 sessionId。0 全表扫描、0 IO 读、0 LLM。
|
|
11
|
+
* - `backfill`(一次性):分页扫历史记录,从 `metadata.dsh_source_anchors` 复原会话
|
|
12
|
+
* 归属,让装了很久的老库不必再跨 N 个会话才见第一条技能。
|
|
13
|
+
* 两者共用 `crystallize` 的 `groupKey`/`fnv1a`,保证同一组在两条路径算出**同一个**
|
|
14
|
+
* 候选 id(否则会出现两个重复候选,幂等崩)。
|
|
15
|
+
*
|
|
16
|
+
* **三条不可让步的纪律**:
|
|
17
|
+
* 1. 必须投 `evidence`——`SkillStore.sessionCount` 只数 evidence 行、不看
|
|
18
|
+
* `Skill.evidenceRecordIds`;只 create 不 appendEvidence 的话,技能永远停在
|
|
19
|
+
* candidate 且 distinct-session 恒 0,**即使 allowPromote 打开也永不晋升**。
|
|
20
|
+
* 2. 只覆盖 `steps`,绝不传 `body`——`body` 是人编辑区(`skill-store.ts` 头部分列
|
|
21
|
+
* 不变量),机器侧只能重写 checklist 步骤。
|
|
22
|
+
* 3. 不调 `getAllL1()`/`l1.all()`——那两个方法一次性全量同步反序列化含正文,
|
|
23
|
+
* 在事件循环里会阻塞所有 RPC(`sqlite.ts` 里 `getAllL1Lite` 的存在理由注释已写明)。
|
|
24
|
+
*
|
|
25
|
+
* **候选首见即建,但不注入**:`appendEvidence` 要求技能已存在,所以无法"攒够阈再建";
|
|
26
|
+
* 而 `status:'candidate'` 本身不进召回(`skill-recall` 只注入 active),因此早期建
|
|
27
|
+
* 候选没有注意力税。"是否达阈"由面板按 sessionCount/minSessions 如实显示。
|
|
28
|
+
*/
|
|
29
|
+
import type { MemoryConfig } from '../config.js';
|
|
30
|
+
import type { L1Store } from '../store/l1.js';
|
|
31
|
+
import type { SkillStore } from '../store/skill-store.js';
|
|
32
|
+
import type { MemoryLogger, MemoryRecord } from '../types.js';
|
|
33
|
+
type SkillsConfig = MemoryConfig['skills'];
|
|
34
|
+
/** 一次投递的账目。`errors` 只收要人看的失败原因,不抛穿调用方。 */
|
|
35
|
+
export interface SkillIngestSummary {
|
|
36
|
+
/** 入参记录总数(含被过滤掉的其它类型)。 */
|
|
37
|
+
scanned: number;
|
|
38
|
+
/** 其中 work_method 且正文非空的条数。 */
|
|
39
|
+
methods: number;
|
|
40
|
+
/** 本轮触及的候选数。 */
|
|
41
|
+
candidatesTouched: number;
|
|
42
|
+
created: number;
|
|
43
|
+
/** 新投的证据条数(去重后)。 */
|
|
44
|
+
appended: number;
|
|
45
|
+
promoted: number;
|
|
46
|
+
/** backfill 专用:因缺锚点而跳过的记录数(增量路径恒 0)。 */
|
|
47
|
+
skippedNoAnchor: number;
|
|
48
|
+
/** 是否因技能数达上限而提前停止。 */
|
|
49
|
+
stoppedAtCap: boolean;
|
|
50
|
+
errors: string[];
|
|
51
|
+
}
|
|
52
|
+
export interface SkillBackfillSummary extends SkillIngestSummary {
|
|
53
|
+
/** 实际翻页次数(可观测:判断大库回灌成本)。 */
|
|
54
|
+
pages: number;
|
|
55
|
+
/** 是否因扫描上限被截断。 */
|
|
56
|
+
truncated: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** 生产者健康态:面板三态判定与 `skills-list` 透出的数据源。 */
|
|
59
|
+
export interface SkillProducerStatus {
|
|
60
|
+
/** 本版起恒为 true;保留该字段是为了让面板能区分"有生产者"与旧版空盒子。 */
|
|
61
|
+
wired: true;
|
|
62
|
+
lastRoundAt?: string;
|
|
63
|
+
backfilledAt?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface SkillProducer {
|
|
66
|
+
/** 稳态入口:本轮新记录 + 本轮 sessionId。 */
|
|
67
|
+
ingestRound(records: readonly MemoryRecord[], sessionId: string, runId: string): Promise<SkillIngestSummary>;
|
|
68
|
+
/** 一次性历史回灌(面板「立即检测历史」按钮与 `skills-detect` RPC 的落点)。 */
|
|
69
|
+
backfill(opts?: {
|
|
70
|
+
maxRecords?: number;
|
|
71
|
+
page?: number;
|
|
72
|
+
}): Promise<SkillBackfillSummary>;
|
|
73
|
+
status(): SkillProducerStatus;
|
|
74
|
+
}
|
|
75
|
+
export interface SkillProducerDeps {
|
|
76
|
+
skills: SkillStore;
|
|
77
|
+
l1: L1Store;
|
|
78
|
+
/** 函数式取配置:面板改阈值后下一轮即生效,不必重启。 */
|
|
79
|
+
cfg: () => SkillsConfig;
|
|
80
|
+
logger?: MemoryLogger;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 建一个技能生产者。
|
|
84
|
+
*
|
|
85
|
+
* 所有失败都在内部消化成 `summary.errors` + warn 日志,**不抛穿调用方**——
|
|
86
|
+
* 蒸馏轮次不该因为技能结晶失败而丢记忆。
|
|
87
|
+
*/
|
|
88
|
+
export declare function createSkillProducer(deps: SkillProducerDeps): SkillProducer;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { ANCHOR_METADATA_KEY } from '../pipeline/anchors.js';
|
|
2
|
+
import { fnv1a, groupKey } from './crystallize.js';
|
|
3
|
+
/** L1 词表里的「工作方法」类型字面量(技能只从这一类派生,不新增类型)。 */
|
|
4
|
+
const WORK_METHOD_TYPE = 'work_method';
|
|
5
|
+
/** 证据行的来源标记:区分蒸馏轮次投的与人工补的。 */
|
|
6
|
+
const EVIDENCE_KIND = 'distill';
|
|
7
|
+
/** backfill 分页大小与扫描上限:大库必须可截断,不许无界扫。 */
|
|
8
|
+
const BACKFILL_PAGE = 500;
|
|
9
|
+
const BACKFILL_MAX_RECORDS = 20_000;
|
|
10
|
+
/** 单候选最多收录的步骤数与证据 id 数(与 crystallize 的默认口径一致)。 */
|
|
11
|
+
const MAX_STEPS = 20;
|
|
12
|
+
const EVIDENCE_IDS_CAP = 200;
|
|
13
|
+
/** 候选 body 的首段截取长度(steps[0] 的摘要,人可后续编辑)。 */
|
|
14
|
+
const BODY_LEAD_CHARS = 200;
|
|
15
|
+
/**
|
|
16
|
+
* 把 L1 记录归一成检测用的视图:`hall` 住在 `metadata.hall`(Wings 关时缺省 ⇒
|
|
17
|
+
* groupKey 回落 'general'),`scene_name` 是 snake_case 列,`sessionId` 由调用方注入
|
|
18
|
+
* (**记录本身不存 sessionId**,这是本文件存在的关键约束之一)。
|
|
19
|
+
*/
|
|
20
|
+
function toViews(records, sessionId) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const r of records) {
|
|
23
|
+
if (!r || r.type !== WORK_METHOD_TYPE)
|
|
24
|
+
continue;
|
|
25
|
+
if (typeof r.content !== 'string' || r.content.trim() === '')
|
|
26
|
+
continue;
|
|
27
|
+
const hall = typeof r.metadata?.['hall'] === 'string' ? r.metadata['hall'] : undefined;
|
|
28
|
+
out.push({ id: r.id, content: r.content, sessionId, hall, sceneName: r.scene_name });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 从锚点展开一条历史记录到「每会话一条视图」。
|
|
34
|
+
*
|
|
35
|
+
* 无锚点或锚点里 sessionId 全空 ⇒ 返回空数组(调用方计入 `skippedNoAnchor`)。
|
|
36
|
+
* **绝不回落成 `'default'`**:伪造的会话归属会虚增 distinct-session,直接骗过晋升门。
|
|
37
|
+
*/
|
|
38
|
+
function viewsFromAnchors(r) {
|
|
39
|
+
if (!r || r.type !== WORK_METHOD_TYPE)
|
|
40
|
+
return { views: [], sessions: 0 };
|
|
41
|
+
if (typeof r.content !== 'string' || r.content.trim() === '')
|
|
42
|
+
return { views: [], sessions: 0 };
|
|
43
|
+
const raw = r.metadata?.[ANCHOR_METADATA_KEY];
|
|
44
|
+
if (!Array.isArray(raw))
|
|
45
|
+
return { views: [], sessions: 0 };
|
|
46
|
+
const hall = typeof r.metadata?.['hall'] === 'string' ? r.metadata['hall'] : undefined;
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const views = [];
|
|
49
|
+
for (const a of raw) {
|
|
50
|
+
const sid = typeof a?.sessionId === 'string' ? a.sessionId.trim() : '';
|
|
51
|
+
if (sid === '' || seen.has(sid))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(sid);
|
|
54
|
+
views.push({ id: r.id, content: r.content, sessionId: sid, hall, sceneName: r.scene_name });
|
|
55
|
+
}
|
|
56
|
+
return { views, sessions: seen.size };
|
|
57
|
+
}
|
|
58
|
+
function buildGroups(views) {
|
|
59
|
+
const m = new Map();
|
|
60
|
+
for (const v of views) {
|
|
61
|
+
const key = groupKey(v);
|
|
62
|
+
const g = m.get(key);
|
|
63
|
+
if (g)
|
|
64
|
+
g.views.push(v);
|
|
65
|
+
else
|
|
66
|
+
m.set(key, { key, id: 'skx-' + fnv1a(key), hall: v.hall, sceneName: v.sceneName, views: [v] });
|
|
67
|
+
}
|
|
68
|
+
// 按 key 排序:与 detectCandidates 同口径,让日志与快照可复现。
|
|
69
|
+
return [...m.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
70
|
+
}
|
|
71
|
+
/** 归一化去重步骤(空白折叠 + 小写比较,保留首次出现顺序)。 */
|
|
72
|
+
function dedupe(views) {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const steps = [];
|
|
75
|
+
const recordIds = [];
|
|
76
|
+
for (const v of views) {
|
|
77
|
+
const norm = v.content.replace(/\s+/g, ' ').trim();
|
|
78
|
+
if (norm === '')
|
|
79
|
+
continue;
|
|
80
|
+
const probe = norm.toLowerCase();
|
|
81
|
+
if (seen.has(probe))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(probe);
|
|
84
|
+
if (steps.length < MAX_STEPS)
|
|
85
|
+
steps.push(norm);
|
|
86
|
+
if (recordIds.length < EVIDENCE_IDS_CAP && !recordIds.includes(v.id))
|
|
87
|
+
recordIds.push(v.id);
|
|
88
|
+
}
|
|
89
|
+
return { steps, recordIds };
|
|
90
|
+
}
|
|
91
|
+
/** 候选标题:与 detectCandidates 的 label 公式逐字一致(两处漂移会让同一组显示两个名字)。 */
|
|
92
|
+
function titleOf(g) {
|
|
93
|
+
const label = g.hall && g.hall !== 'general' ? `${g.hall} 流程` : g.sceneName ? `${g.sceneName} 流程` : '通用流程';
|
|
94
|
+
return `技能:${label}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 建一个技能生产者。
|
|
98
|
+
*
|
|
99
|
+
* 所有失败都在内部消化成 `summary.errors` + warn 日志,**不抛穿调用方**——
|
|
100
|
+
* 蒸馏轮次不该因为技能结晶失败而丢记忆。
|
|
101
|
+
*/
|
|
102
|
+
export function createSkillProducer(deps) {
|
|
103
|
+
const { skills, l1, logger } = deps;
|
|
104
|
+
let lastRoundAt;
|
|
105
|
+
let backfilledAt;
|
|
106
|
+
/** 空账目(开关关时直接返回,全程零动作)。 */
|
|
107
|
+
const emptySummary = () => ({
|
|
108
|
+
scanned: 0,
|
|
109
|
+
methods: 0,
|
|
110
|
+
candidatesTouched: 0,
|
|
111
|
+
created: 0,
|
|
112
|
+
appended: 0,
|
|
113
|
+
promoted: 0,
|
|
114
|
+
skippedNoAnchor: 0,
|
|
115
|
+
stoppedAtCap: false,
|
|
116
|
+
errors: [],
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* 把一组视图落成「一个候选 + 若干证据」,并按需尝试晋升。
|
|
120
|
+
* 返回 false 表示应当中止外层循环(达 SKILLS_CAP)。
|
|
121
|
+
*/
|
|
122
|
+
const absorbGroup = async (g, runId, cfg, s) => {
|
|
123
|
+
const { steps, recordIds } = dedupe(g.views);
|
|
124
|
+
if (steps.length === 0)
|
|
125
|
+
return true;
|
|
126
|
+
let exists = skills.get(g.id) !== undefined;
|
|
127
|
+
if (!exists) {
|
|
128
|
+
try {
|
|
129
|
+
await skills.create({
|
|
130
|
+
id: g.id,
|
|
131
|
+
title: titleOf(g),
|
|
132
|
+
body: (steps[0] ?? '').slice(0, BODY_LEAD_CHARS),
|
|
133
|
+
steps,
|
|
134
|
+
trigger: { hall: g.hall, sceneName: g.sceneName },
|
|
135
|
+
status: 'candidate',
|
|
136
|
+
family: 'work',
|
|
137
|
+
evidenceRecordIds: recordIds,
|
|
138
|
+
});
|
|
139
|
+
s.created += 1;
|
|
140
|
+
exists = true;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
if (msg.includes('上限')) {
|
|
145
|
+
s.stoppedAtCap = true;
|
|
146
|
+
s.errors.push('技能数已达上限,本轮停止结晶(可在面板归档部分技能)');
|
|
147
|
+
logger?.warn('[memory] 技能结晶停止:技能数已达上限');
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (!msg.includes('已存在')) {
|
|
151
|
+
s.errors.push(`建候选失败: ${msg}`);
|
|
152
|
+
logger?.warn(`[memory] 技能候选创建失败(${g.id}): ${msg}`);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
// 并发下另一轮刚建好:当作已存在,继续走证据路径
|
|
156
|
+
exists = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// 机器侧只重写 steps;body 是人编辑区,传进去就破坏了分列不变量。
|
|
161
|
+
try {
|
|
162
|
+
await skills.update(g.id, { steps });
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
s.errors.push(`更新步骤失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
s.candidatesTouched += 1;
|
|
169
|
+
// 证据:sessionCount 的唯一来源。三元组去重,重复轮次不重复投。
|
|
170
|
+
const at = new Date().toISOString();
|
|
171
|
+
for (const v of g.views) {
|
|
172
|
+
if (v.sessionId === '')
|
|
173
|
+
continue;
|
|
174
|
+
if (skills.hasEvidence(g.id, v.sessionId, v.id))
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
await skills.appendEvidence({ skillId: g.id, runId, recordId: v.id, sessionId: v.sessionId, at, kind: EVIDENCE_KIND });
|
|
178
|
+
s.appended += 1;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
s.errors.push(`投证据失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 自动晋升:双阈同时满足才动(minEvidence 此前无人消费,这里让它真实生效)。
|
|
185
|
+
// 人工晋升走 skills-action,只卡 sessionCount——人审是人的判断,不重复机械门。
|
|
186
|
+
if (cfg.allowPromote && exists) {
|
|
187
|
+
if (skills.sessionCount(g.id) >= cfg.minSessions && skills.evidenceCount(g.id) >= cfg.minEvidence) {
|
|
188
|
+
if (await skills.tryPromote(g.id, cfg.minSessions, { allowPromote: true }))
|
|
189
|
+
s.promoted += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
};
|
|
194
|
+
const drainGroups = async (groups, runId, cfg, s) => {
|
|
195
|
+
for (const g of groups) {
|
|
196
|
+
if (!(await absorbGroup(g, runId, cfg, s)))
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
async ingestRound(records, sessionId, runId) {
|
|
202
|
+
const s = emptySummary();
|
|
203
|
+
const cfg = deps.cfg();
|
|
204
|
+
s.scanned = records.length;
|
|
205
|
+
if (!cfg.enabled || records.length === 0)
|
|
206
|
+
return s;
|
|
207
|
+
const views = toViews(records, sessionId);
|
|
208
|
+
s.methods = views.length;
|
|
209
|
+
if (views.length === 0)
|
|
210
|
+
return s;
|
|
211
|
+
try {
|
|
212
|
+
await drainGroups(buildGroups(views), runId, cfg, s);
|
|
213
|
+
lastRoundAt = new Date().toISOString();
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
s.errors.push(err instanceof Error ? err.message : String(err));
|
|
217
|
+
}
|
|
218
|
+
if (s.candidatesTouched > 0 || s.created > 0) {
|
|
219
|
+
logger?.info(`[memory] 技能结晶:触及 ${s.candidatesTouched} 候选(新建 ${s.created}、证据 +${s.appended}${s.promoted > 0 ? `、晋升 ${s.promoted}` : ''})`);
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
},
|
|
223
|
+
async backfill(opts) {
|
|
224
|
+
const s = { ...emptySummary(), pages: 0, truncated: false };
|
|
225
|
+
const cfg = deps.cfg();
|
|
226
|
+
if (!cfg.enabled)
|
|
227
|
+
return s;
|
|
228
|
+
const page = opts?.page !== undefined && opts.page > 0 ? opts.page : BACKFILL_PAGE;
|
|
229
|
+
const maxRecords = opts?.maxRecords !== undefined && opts.maxRecords > 0 ? opts.maxRecords : BACKFILL_MAX_RECORDS;
|
|
230
|
+
// 累积全部会话的视图后一次性分组:跨页分裂的同组记录必须合成一个候选,
|
|
231
|
+
// 所以不能在页内直接落库。上限由 maxRecords 兜住,内存里只放视图引用。
|
|
232
|
+
const views = [];
|
|
233
|
+
try {
|
|
234
|
+
for (let offset = 0;; offset += page) {
|
|
235
|
+
const lite = l1.allLite(page, offset);
|
|
236
|
+
s.pages += 1;
|
|
237
|
+
if (lite.length === 0)
|
|
238
|
+
break;
|
|
239
|
+
s.scanned += lite.length;
|
|
240
|
+
const ids = lite.filter((r) => r.type === WORK_METHOD_TYPE).map((r) => r.id);
|
|
241
|
+
if (ids.length > 0) {
|
|
242
|
+
for (const r of l1.getByIds(ids)) {
|
|
243
|
+
const { views: vs } = viewsFromAnchors(r);
|
|
244
|
+
if (vs.length === 0) {
|
|
245
|
+
if (r.content.trim() !== '' && r.type === WORK_METHOD_TYPE)
|
|
246
|
+
s.skippedNoAnchor += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
views.push(...vs);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (s.scanned >= maxRecords) {
|
|
253
|
+
s.truncated = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
// 让出事件循环:大库回灌期间面板 RPC 不能被独占(对齐 allLite 的存在理由)。
|
|
257
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
258
|
+
}
|
|
259
|
+
s.methods = new Set(views.map((v) => v.id)).size;
|
|
260
|
+
await drainGroups(buildGroups(views), `backfill-${Date.now().toString(36)}`, cfg, s);
|
|
261
|
+
backfilledAt = new Date().toISOString();
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
s.errors.push(err instanceof Error ? err.message : String(err));
|
|
265
|
+
logger?.warn(`[memory] 技能历史回灌失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
266
|
+
}
|
|
267
|
+
logger?.info(`[memory] 技能历史回灌:扫 ${s.scanned} 条记录(${s.pages} 页${s.truncated ? ',达上限截断' : ''})→ 触及 ${s.candidatesTouched} 候选(新建 ${s.created}、证据 +${s.appended}${s.skippedNoAnchor > 0 ? `、${s.skippedNoAnchor} 条缺锚点跳过` : ''})`);
|
|
268
|
+
return s;
|
|
269
|
+
},
|
|
270
|
+
status() {
|
|
271
|
+
return { wired: true, ...(lastRoundAt === undefined ? {} : { lastRoundAt }), ...(backfilledAt === undefined ? {} : { backfilledAt }) };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { RuminateController } from './pipeline/ruminate.js';
|
|
|
6
6
|
import type { RoomRegistryStore } from './store/rooms-registry.js';
|
|
7
7
|
import type { MemorySourceStore } from './store/memory-sources.js';
|
|
8
8
|
import type { SkillStore } from './store/skill-store.js';
|
|
9
|
+
import type { SkillProducer } from './skills/producer.js';
|
|
9
10
|
import type { RecallUsageStore } from './store/recall-usage.js';
|
|
10
11
|
import { type LiveSettingsHandle } from './settings.js';
|
|
11
12
|
import type { GraphStore } from './store/graph-store.js';
|
|
@@ -89,6 +90,8 @@ interface MemoryRpcSources {
|
|
|
89
90
|
memorySources?: MemorySourceStore;
|
|
90
91
|
/** 技能存储(轨2;未装配 = skills-* 不可用)。 */
|
|
91
92
|
skillStore?: SkillStore;
|
|
93
|
+
/** 技能结晶生产者(未装配 = skills-detect 不可用、skills-list 不带 producer 态)。 */
|
|
94
|
+
skillProducer?: SkillProducer;
|
|
92
95
|
}
|
|
93
96
|
/** 端点 deps 的注入面(ctx/cfg/stores/logger 由调用方绑定,其余由此处决定)。 */
|
|
94
97
|
export type EndpointDepsInput = Omit<EndpointDeps, 'ctx' | 'cfg' | 'stores' | 'logger'>;
|
|
@@ -118,7 +121,9 @@ roomRegistry?: RoomRegistryStore,
|
|
|
118
121
|
/** 外部来源注册表(轨3 Connect;经 buildEndpointDeps 落入 deps.memorySources)。 */
|
|
119
122
|
memorySources?: MemorySourceStore,
|
|
120
123
|
/** 技能存储(轨2;经 buildEndpointDeps 落入 deps.skillStore)。 */
|
|
121
|
-
skillStore?: SkillStore
|
|
124
|
+
skillStore?: SkillStore,
|
|
125
|
+
/** 技能结晶生产者(轨2;经 buildEndpointDeps 落入 deps.skillProducer)。 */
|
|
126
|
+
skillProducer?: SkillProducer): void;
|
|
122
127
|
export interface EndpointDeps {
|
|
123
128
|
ctx: Context;
|
|
124
129
|
cfg: MemoryConfig;
|
|
@@ -157,6 +162,8 @@ export interface EndpointDeps {
|
|
|
157
162
|
memorySources?: MemorySourceStore;
|
|
158
163
|
/** 技能存储(轨2;未装配 = skills-* 不可用)。 */
|
|
159
164
|
skillStore?: SkillStore;
|
|
165
|
+
/** 技能结晶生产者(轨2;未装配 = skills-detect 不可用,面板据此区分新旧版)。 */
|
|
166
|
+
skillProducer?: SkillProducer;
|
|
160
167
|
}
|
|
161
168
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
162
169
|
export declare function handleEndpoint(endpoint: string, payload: unknown, deps: EndpointDeps): Promise<unknown>;
|
package/dist/stats.js
CHANGED
|
@@ -139,6 +139,7 @@ export const MEMORY_ENDPOINTS = [
|
|
|
139
139
|
'dsh-memory/memory-sources-retire',
|
|
140
140
|
'dsh-memory/memport-scan',
|
|
141
141
|
'dsh-memory/skills-list',
|
|
142
|
+
'dsh-memory/skills-detect',
|
|
142
143
|
'dsh-memory/session-recalled',
|
|
143
144
|
'dsh-memory/recall-feedback',
|
|
144
145
|
'dsh-memory/workspace-relink',
|
|
@@ -247,6 +248,7 @@ export function buildEndpointDeps(base, sources, controller) {
|
|
|
247
248
|
roomRegistry: sources.roomRegistry,
|
|
248
249
|
memorySources: sources.memorySources,
|
|
249
250
|
skillStore: sources.skillStore,
|
|
251
|
+
skillProducer: sources.skillProducer,
|
|
250
252
|
};
|
|
251
253
|
return { ...base, ...injected };
|
|
252
254
|
}
|
|
@@ -258,7 +260,9 @@ roomRegistry,
|
|
|
258
260
|
/** 外部来源注册表(轨3 Connect;经 buildEndpointDeps 落入 deps.memorySources)。 */
|
|
259
261
|
memorySources,
|
|
260
262
|
/** 技能存储(轨2;经 buildEndpointDeps 落入 deps.skillStore)。 */
|
|
261
|
-
skillStore
|
|
263
|
+
skillStore,
|
|
264
|
+
/** 技能结晶生产者(轨2;经 buildEndpointDeps 落入 deps.skillProducer)。 */
|
|
265
|
+
skillProducer) {
|
|
262
266
|
/** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
|
|
263
267
|
let holding = false;
|
|
264
268
|
/** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
|
|
@@ -279,7 +283,7 @@ skillStore) {
|
|
|
279
283
|
const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger },
|
|
280
284
|
// roomRegistry 必须随 sources 下发——漏带 = 面板侧 room-* 恒"未装配"
|
|
281
285
|
// (工具面直传不受影响;2026-10-02 beta.9 实测回归)
|
|
282
|
-
{ status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry, memorySources, skillStore }, ruminate));
|
|
286
|
+
{ status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry, memorySources, skillStore, skillProducer }, ruminate));
|
|
283
287
|
return { ok: true, value };
|
|
284
288
|
}
|
|
285
289
|
catch (err) {
|
|
@@ -427,7 +431,7 @@ function sanitizeSettings(s) {
|
|
|
427
431
|
}
|
|
428
432
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
429
433
|
export async function handleEndpoint(endpoint, payload, deps) {
|
|
430
|
-
const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry, memorySources, skillStore } = deps;
|
|
434
|
+
const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry, memorySources, skillStore, skillProducer } = deps;
|
|
431
435
|
// 退场/恢复/新增的文件跟随已收口在 `L1Store` 的汇流口(`store/memory-md-sync.ts`),
|
|
432
436
|
// 本分发层不再自己建点火器——那 8 处事件挂钩已被真机证伪(会漏)。
|
|
433
437
|
switch (endpoint) {
|
|
@@ -923,10 +927,55 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
923
927
|
tier: s.injectionTier,
|
|
924
928
|
pinned: s.pinned,
|
|
925
929
|
sessionCount: skillStore.sessionCount(s.id),
|
|
930
|
+
evidenceCount: skillStore.evidenceCount(s.id),
|
|
926
931
|
updatedAt: s.updatedAt,
|
|
927
932
|
}))
|
|
928
933
|
: [],
|
|
929
934
|
};
|
|
935
|
+
// 生产者健康态:面板靠它区分“旧版空盒子”与“已接线但未达阈”。
|
|
936
|
+
// 未装配生产者(测试缝/降级)时不附此字段,面板回到旧文案而不谎报已接线。
|
|
937
|
+
if (enabled && skillProducer && cfg.skills) {
|
|
938
|
+
const st = skillProducer.status();
|
|
939
|
+
const producer = {
|
|
940
|
+
wired: st.wired,
|
|
941
|
+
workMethodCount: stores.l1.countByType('work_method'),
|
|
942
|
+
candidateCount: skillStore?.list().filter((s) => s.status === 'candidate').length ?? 0,
|
|
943
|
+
minSessions: cfg.skills.minSessions,
|
|
944
|
+
minEvidence: cfg.skills.minEvidence,
|
|
945
|
+
...(st.lastRoundAt === undefined ? {} : { lastRoundAt: st.lastRoundAt }),
|
|
946
|
+
...(st.backfilledAt === undefined ? {} : { backfilledAt: st.backfilledAt }),
|
|
947
|
+
};
|
|
948
|
+
v.producer = producer;
|
|
949
|
+
}
|
|
950
|
+
return v;
|
|
951
|
+
}
|
|
952
|
+
// 手动回灌历史:一次性扫存量 work_method,从锚点复原会话归属。
|
|
953
|
+
// 写库动作,所以与 skills-action 同走「高权限模式」门。
|
|
954
|
+
case 'dsh-memory/skills-detect': {
|
|
955
|
+
if (!(cfg.skills?.enabled === true || live?.get().skillsEnabled === true))
|
|
956
|
+
throw new Error('技能功能未启用(在概览页开启「技能」开关,或 config skills.enabled)。');
|
|
957
|
+
if (!skillProducer)
|
|
958
|
+
throw new Error('技能生产者未装配(本版本不应出现)。');
|
|
959
|
+
if (!live?.get().memoryMutate)
|
|
960
|
+
throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后才能回灌技能。');
|
|
961
|
+
const p = (payload ?? {});
|
|
962
|
+
const r = await skillProducer.backfill(p.maxRecords !== undefined ? { maxRecords: p.maxRecords } : undefined);
|
|
963
|
+
const notice = r.truncated
|
|
964
|
+
? `已扫 ${r.scanned} 条(达上限截断):新建 ${r.created} 候选、补投证据 ${r.appended} 条。`
|
|
965
|
+
: `已扫 ${r.scanned} 条:新建 ${r.created} 候选、补投证据 ${r.appended} 条${r.skippedNoAnchor > 0 ? `(${r.skippedNoAnchor} 条缺会话锚点已跳过)` : ''}。`;
|
|
966
|
+
const v = {
|
|
967
|
+
ok: r.errors.length === 0,
|
|
968
|
+
scanned: r.scanned,
|
|
969
|
+
methods: r.methods,
|
|
970
|
+
created: r.created,
|
|
971
|
+
appended: r.appended,
|
|
972
|
+
promoted: r.promoted,
|
|
973
|
+
skippedNoAnchor: r.skippedNoAnchor,
|
|
974
|
+
pages: r.pages,
|
|
975
|
+
truncated: r.truncated,
|
|
976
|
+
notice,
|
|
977
|
+
...(r.errors.length === 0 ? {} : { error: r.errors.join(' | ') }),
|
|
978
|
+
};
|
|
930
979
|
return v;
|
|
931
980
|
}
|
|
932
981
|
case 'dsh-memory/skills-action': {
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -112,6 +112,13 @@ export declare class L1Store {
|
|
|
112
112
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
113
113
|
private importLegacy;
|
|
114
114
|
get size(): number;
|
|
115
|
+
/**
|
|
116
|
+
* 按类型计数(索引级,不拉正文)。
|
|
117
|
+
*
|
|
118
|
+
* 当前唯一消费方是技能面板的诚实化判定:要区分“池里没有可结晶的工作方法”
|
|
119
|
+
* 与“有但未达阈”,两者给用户的处置建议完全不同。
|
|
120
|
+
*/
|
|
121
|
+
countByType(type: string): number;
|
|
115
122
|
/** 全量读取(调试/迁移用;检索请走 search)。 */
|
|
116
123
|
all(): MemoryRecord[];
|
|
117
124
|
/**
|
package/dist/store/l1.js
CHANGED
|
@@ -134,6 +134,15 @@ export class L1Store {
|
|
|
134
134
|
get size() {
|
|
135
135
|
return this.db.countL1();
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* 按类型计数(索引级,不拉正文)。
|
|
139
|
+
*
|
|
140
|
+
* 当前唯一消费方是技能面板的诚实化判定:要区分“池里没有可结晶的工作方法”
|
|
141
|
+
* 与“有但未达阈”,两者给用户的处置建议完全不同。
|
|
142
|
+
*/
|
|
143
|
+
countByType(type) {
|
|
144
|
+
return this.db.countL1ByType(type);
|
|
145
|
+
}
|
|
137
146
|
/** 全量读取(调试/迁移用;检索请走 search)。 */
|
|
138
147
|
all() {
|
|
139
148
|
return this.db.getAllL1();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* 与正文本体是否一致(`hashValid`),不替调用方决定怎么处置——那属于同步侧的裁决。
|
|
14
14
|
*/
|
|
15
15
|
import { contentHash } from './memory-md.js';
|
|
16
|
+
import { clampPriority } from '../util/priority.js';
|
|
16
17
|
/** 取 frontmatter 块:首行必须是 `---`,到下一个 `---` 为止。 */
|
|
17
18
|
function splitFrontmatter(text) {
|
|
18
19
|
// BOM 与 CRLF 都要先吃掉:人在记事本里改一行就会引入 \r,而 `\r` 会让 key 匹配不上。
|
|
@@ -71,7 +72,7 @@ export function parseMdFile(text) {
|
|
|
71
72
|
return {
|
|
72
73
|
name: str(fields.get('name')),
|
|
73
74
|
type: str(fields.get('type')),
|
|
74
|
-
priority:
|
|
75
|
+
priority: clampPriority(prio),
|
|
75
76
|
createdAt: str(fields.get('createdAt')),
|
|
76
77
|
updatedAt: str(fields.get('updatedAt')),
|
|
77
78
|
usageScenario: scenario,
|
|
@@ -57,6 +57,7 @@ export declare class SkillStore {
|
|
|
57
57
|
private evidence;
|
|
58
58
|
private rev;
|
|
59
59
|
private loaded;
|
|
60
|
+
private fileAbsentAtBoot;
|
|
60
61
|
private degraded;
|
|
61
62
|
private degradedLogged;
|
|
62
63
|
private writeChain;
|
|
@@ -75,6 +76,26 @@ export declare class SkillStore {
|
|
|
75
76
|
update(id: string, patch: Partial<Pick<Skill, 'title' | 'body' | 'steps' | 'injectionTier' | 'pinned'>>): Promise<boolean>;
|
|
76
77
|
/** 追加一条证据(只追加);返回该技能当前的 distinct-session 数。 */
|
|
77
78
|
appendEvidence(ev: SkillEvidence): Promise<number>;
|
|
79
|
+
/**
|
|
80
|
+
* 启动时 `skills.json` 是否不存在(缺文件是合法空表,但供诚实化诊断用)。
|
|
81
|
+
* 取的是 **boot 快照**,不随本轮写入变化——开关开了但文件一直没生出来,
|
|
82
|
+
* 就是"无生产者"的历史现场,启动日志据此提 warning。
|
|
83
|
+
*/
|
|
84
|
+
wasAbsentAtBoot(): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* 该技能是否已收录过这条证据三元组 (skill, session, record)。
|
|
87
|
+
*
|
|
88
|
+
* 存在的必要:`appendEvidence` 是**纯追加**,而 `evidence` 数组触顶
|
|
89
|
+
* (`EVIDENCE_CAP`)时会 `splice` 掉最老的行。backfill 与增量轮次对同一证据
|
|
90
|
+
* 重复投,会把真实历史挤出去——所以投前必过这道门(producer 两条路径同源)。
|
|
91
|
+
*
|
|
92
|
+
* 为何是三元组而不是 (skill, record):backfill 下一条历史记录可经
|
|
93
|
+
* `metadata.dsh_source_anchors` 映射到**多个**会话,按二元组去重会抹掉
|
|
94
|
+
* 第 2、3 个会话,而 distinct-session 正是晋升判据。
|
|
95
|
+
*/
|
|
96
|
+
hasEvidence(skillId: string, sessionId: string, recordId: string): boolean;
|
|
97
|
+
/** 某技能的证据总条数(`minEvidence` 的度量;与 `sessionCount` 的 distinct 语义不同)。 */
|
|
98
|
+
evidenceCount(skillId: string): number;
|
|
78
99
|
/** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
|
|
79
100
|
sessionCount(skillId: string): number;
|
|
80
101
|
/**
|
|
@@ -75,6 +75,7 @@ export class SkillStore {
|
|
|
75
75
|
evidence = [];
|
|
76
76
|
rev = 0;
|
|
77
77
|
loaded = false;
|
|
78
|
+
fileAbsentAtBoot = false;
|
|
78
79
|
degraded;
|
|
79
80
|
degradedLogged = false;
|
|
80
81
|
writeChain = Promise.resolve();
|
|
@@ -87,7 +88,9 @@ export class SkillStore {
|
|
|
87
88
|
await ensureDir(path.dirname(this.file));
|
|
88
89
|
const read = await readJsonStrict(this.file);
|
|
89
90
|
if (!read.ok) {
|
|
90
|
-
if (read.reason
|
|
91
|
+
if (read.reason === 'missing')
|
|
92
|
+
this.fileAbsentAtBoot = true;
|
|
93
|
+
else {
|
|
91
94
|
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
92
95
|
this.logger?.warn(`[memory] 技能存储只读降级(内存态照常,停止回写): ${this.degraded}`);
|
|
93
96
|
}
|
|
@@ -221,6 +224,42 @@ export class SkillStore {
|
|
|
221
224
|
await this.persist();
|
|
222
225
|
return this.sessionCount(ev.skillId);
|
|
223
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* 启动时 `skills.json` 是否不存在(缺文件是合法空表,但供诚实化诊断用)。
|
|
229
|
+
* 取的是 **boot 快照**,不随本轮写入变化——开关开了但文件一直没生出来,
|
|
230
|
+
* 就是"无生产者"的历史现场,启动日志据此提 warning。
|
|
231
|
+
*/
|
|
232
|
+
wasAbsentAtBoot() {
|
|
233
|
+
return this.fileAbsentAtBoot;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* 该技能是否已收录过这条证据三元组 (skill, session, record)。
|
|
237
|
+
*
|
|
238
|
+
* 存在的必要:`appendEvidence` 是**纯追加**,而 `evidence` 数组触顶
|
|
239
|
+
* (`EVIDENCE_CAP`)时会 `splice` 掉最老的行。backfill 与增量轮次对同一证据
|
|
240
|
+
* 重复投,会把真实历史挤出去——所以投前必过这道门(producer 两条路径同源)。
|
|
241
|
+
*
|
|
242
|
+
* 为何是三元组而不是 (skill, record):backfill 下一条历史记录可经
|
|
243
|
+
* `metadata.dsh_source_anchors` 映射到**多个**会话,按二元组去重会抹掉
|
|
244
|
+
* 第 2、3 个会话,而 distinct-session 正是晋升判据。
|
|
245
|
+
*/
|
|
246
|
+
hasEvidence(skillId, sessionId, recordId) {
|
|
247
|
+
this.ensureLoaded();
|
|
248
|
+
for (const e of this.evidence) {
|
|
249
|
+
if (e.skillId === skillId && e.sessionId === sessionId && e.recordId === recordId)
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
/** 某技能的证据总条数(`minEvidence` 的度量;与 `sessionCount` 的 distinct 语义不同)。 */
|
|
255
|
+
evidenceCount(skillId) {
|
|
256
|
+
this.ensureLoaded();
|
|
257
|
+
let n = 0;
|
|
258
|
+
for (const e of this.evidence)
|
|
259
|
+
if (e.skillId === skillId)
|
|
260
|
+
n++;
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
224
263
|
/** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
|
|
225
264
|
sessionCount(skillId) {
|
|
226
265
|
this.ensureLoaded();
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -220,6 +220,13 @@ export declare class MemoryDb {
|
|
|
220
220
|
*/
|
|
221
221
|
clearL1(): boolean;
|
|
222
222
|
countL1(): number;
|
|
223
|
+
/**
|
|
224
|
+
* 按类型计数(`idx_l1_type` 已建,索引级廉价查询,不拉正文)。
|
|
225
|
+
*
|
|
226
|
+
* 用途单一:技能面板要区分“池里根本没有 work_method 原料”与“有但未达阈”,
|
|
227
|
+
* 这两态的文案与处置动作完全不同,不能都用“暂无技能”糊过去。
|
|
228
|
+
*/
|
|
229
|
+
countL1ByType(type: string): number;
|
|
223
230
|
/**
|
|
224
231
|
* 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
|
|
225
232
|
* 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
|
package/dist/store/sqlite.js
CHANGED
|
@@ -26,6 +26,7 @@ import { normApplicability } from '../repo-scope.js';
|
|
|
26
26
|
import { normalizeWorkspacePath } from '../workspace.js';
|
|
27
27
|
import { bm25RankToScore, buildFtsQuery, tokenizeForFts } from './search-utils.js';
|
|
28
28
|
import { describeTokenizer, ensureTokenizer, tokenizerStamp } from '../util/tokenizer.js';
|
|
29
|
+
import { clampPriority } from '../util/priority.js';
|
|
29
30
|
const require = createRequire(import.meta.url);
|
|
30
31
|
const TAG = '[memory][sqlite]';
|
|
31
32
|
/**
|
|
@@ -992,7 +993,7 @@ export class MemoryDb {
|
|
|
992
993
|
// 无法绑定(node:sqlite 拒绝绑定),曾致旧版导入逐条全挂、每次启动无限重试。
|
|
993
994
|
// 主表与 FTS 两条语句共用同源归一化值;type 归一化后 familyForType 也不再收到 undefined。
|
|
994
995
|
const type = record.type ?? '';
|
|
995
|
-
const priority = record.priority
|
|
996
|
+
const priority = clampPriority(record.priority, 50);
|
|
996
997
|
const sceneName = record.scene_name ?? '';
|
|
997
998
|
const family = record.family ?? familyForType(type);
|
|
998
999
|
// §E 写入侧兜底归一,并维持一条**不变量**:`scope='global'` 的记录 `workspace_id` 恒为空串。
|
|
@@ -1272,6 +1273,23 @@ export class MemoryDb {
|
|
|
1272
1273
|
return 0;
|
|
1273
1274
|
}
|
|
1274
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* 按类型计数(`idx_l1_type` 已建,索引级廉价查询,不拉正文)。
|
|
1278
|
+
*
|
|
1279
|
+
* 用途单一:技能面板要区分“池里根本没有 work_method 原料”与“有但未达阈”,
|
|
1280
|
+
* 这两态的文案与处置动作完全不同,不能都用“暂无技能”糊过去。
|
|
1281
|
+
*/
|
|
1282
|
+
countL1ByType(type) {
|
|
1283
|
+
if (this.degraded)
|
|
1284
|
+
return 0;
|
|
1285
|
+
try {
|
|
1286
|
+
const row = this.db.prepare('SELECT COUNT(*) AS n FROM l1_records WHERE type = ?').get(type);
|
|
1287
|
+
return row?.n ?? 0;
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
return 0;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1275
1293
|
/**
|
|
1276
1294
|
* 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
|
|
1277
1295
|
* 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 记忆 priority 的归一单源(issue #19)。
|
|
3
|
+
*
|
|
4
|
+
* 存在理由:priority 是磁盘契约列,且被 memoryRecall 投影的 `viewSchema` 严格校验
|
|
5
|
+
* (`requireCounter` 判非负整数)。任一写入口把 LLM 回传的负数/超界/小数原样落库,
|
|
6
|
+
* 投影出网即抛 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据
|
|
7
|
+
* 打死整个会话历史**。故所有喂进 `l1_records.priority` 的路径必须过同一夹取。
|
|
8
|
+
*
|
|
9
|
+
* 语义(用户选定):非有限值→`fallback`;小数 `Math.round`;结果夹到整数 `[0,100]`。
|
|
10
|
+
* `fallback` 形参是为保留各写点既有的缺省差异(upsert 绑定层历史上用 50,抽取用 60),
|
|
11
|
+
* 避免为修范围而把某处的默认值静默漂移。
|
|
12
|
+
*
|
|
13
|
+
* 注意:**不动** `store/slots.ts` 里的同名私有 `clampPriority`(floor/50,服务 memorySlots
|
|
14
|
+
* 另一条链);本文件只服务 L1 记忆 priority。
|
|
15
|
+
*/
|
|
16
|
+
export declare const PRIORITY_DEFAULT = 60;
|
|
17
|
+
export declare const PRIORITY_MIN = 0;
|
|
18
|
+
export declare const PRIORITY_MAX = 100;
|
|
19
|
+
/** 夹取到 `[PRIORITY_MIN, PRIORITY_MAX]` 的整数;非有限值回落 `fallback`。 */
|
|
20
|
+
export declare function clampPriority(value: unknown, fallback?: number): number;
|
|
21
|
+
/** 是否会被夹取改变(负 / 超界 / 非整数 / 非有限 / 缺失)——供读侧判定要不要 warn。 */
|
|
22
|
+
export declare function isDirtyPriority(value: unknown): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 记忆 priority 的归一单源(issue #19)。
|
|
3
|
+
*
|
|
4
|
+
* 存在理由:priority 是磁盘契约列,且被 memoryRecall 投影的 `viewSchema` 严格校验
|
|
5
|
+
* (`requireCounter` 判非负整数)。任一写入口把 LLM 回传的负数/超界/小数原样落库,
|
|
6
|
+
* 投影出网即抛 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据
|
|
7
|
+
* 打死整个会话历史**。故所有喂进 `l1_records.priority` 的路径必须过同一夹取。
|
|
8
|
+
*
|
|
9
|
+
* 语义(用户选定):非有限值→`fallback`;小数 `Math.round`;结果夹到整数 `[0,100]`。
|
|
10
|
+
* `fallback` 形参是为保留各写点既有的缺省差异(upsert 绑定层历史上用 50,抽取用 60),
|
|
11
|
+
* 避免为修范围而把某处的默认值静默漂移。
|
|
12
|
+
*
|
|
13
|
+
* 注意:**不动** `store/slots.ts` 里的同名私有 `clampPriority`(floor/50,服务 memorySlots
|
|
14
|
+
* 另一条链);本文件只服务 L1 记忆 priority。
|
|
15
|
+
*/
|
|
16
|
+
export const PRIORITY_DEFAULT = 60;
|
|
17
|
+
export const PRIORITY_MIN = 0;
|
|
18
|
+
export const PRIORITY_MAX = 100;
|
|
19
|
+
/** 夹取到 `[PRIORITY_MIN, PRIORITY_MAX]` 的整数;非有限值回落 `fallback`。 */
|
|
20
|
+
export function clampPriority(value, fallback = PRIORITY_DEFAULT) {
|
|
21
|
+
const sanitize = (x) => Math.max(PRIORITY_MIN, Math.min(PRIORITY_MAX, Math.round(x)));
|
|
22
|
+
// null/undefined 显式回落(Number(null) 得 0,若不当缺省处理会把旧 `?? 50` 漂移成 0)。
|
|
23
|
+
if (value === null || value === undefined)
|
|
24
|
+
return sanitize(fallback);
|
|
25
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
26
|
+
if (!Number.isFinite(n))
|
|
27
|
+
return sanitize(fallback);
|
|
28
|
+
return sanitize(n);
|
|
29
|
+
}
|
|
30
|
+
/** 是否会被夹取改变(负 / 超界 / 非整数 / 非有限 / 缺失)——供读侧判定要不要 warn。 */
|
|
31
|
+
export function isDirtyPriority(value) {
|
|
32
|
+
if (value === null || value === undefined)
|
|
33
|
+
return true;
|
|
34
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
35
|
+
return !Number.isInteger(n) || n < PRIORITY_MIN || n > PRIORITY_MAX;
|
|
36
|
+
}
|
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.
|
|
4
|
+
"version": "0.26.14",
|
|
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 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
|