dsh-prime-memory 0.21.0 → 0.22.2
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/dist/client.js +1581 -1150
- package/dist/config.d.ts +149 -0
- package/dist/config.js +35 -0
- package/dist/conflict-service.d.ts +8 -0
- package/dist/conflict-service.js +19 -0
- package/dist/contract.d.ts +131 -0
- package/dist/domain-gate.d.ts +8 -0
- package/dist/domain-gate.js +30 -0
- package/dist/graph/constraints.d.ts +4 -2
- package/dist/graph/constraints.js +4 -2
- package/dist/hooks/recall.d.ts +2 -0
- package/dist/hooks/recall.js +21 -2
- package/dist/hooks/skill-recall.d.ts +25 -0
- package/dist/hooks/skill-recall.js +72 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +26 -1
- package/dist/memport/format.d.ts +70 -0
- package/dist/memport/format.js +305 -0
- package/dist/memport/prefilter.d.ts +62 -0
- package/dist/memport/prefilter.js +132 -0
- package/dist/memport/scan.d.ts +13 -0
- package/dist/memport/scan.js +100 -0
- package/dist/metadata-validators.d.ts +22 -0
- package/dist/metadata-validators.js +45 -0
- package/dist/pipeline/l1.js +16 -2
- package/dist/pipeline/ruminate.d.ts +3 -0
- package/dist/pipeline/ruminate.js +50 -1
- package/dist/pipeline/slot-migrate.d.ts +45 -0
- package/dist/pipeline/slot-migrate.js +45 -0
- package/dist/projection/recall.d.ts +74 -0
- package/dist/projection/recall.js +148 -0
- package/dist/projection/slots.d.ts +5 -0
- package/dist/projection/slots.js +9 -2
- package/dist/prompts/graph-projection.js +3 -0
- package/dist/prompts/l1-extraction.d.ts +4 -1
- package/dist/prompts/l1-extraction.js +27 -4
- package/dist/settings.js +106 -11
- package/dist/skills/crystallize.d.ts +41 -0
- package/dist/skills/crystallize.js +75 -0
- package/dist/stats.d.ts +18 -1
- package/dist/stats.js +184 -5
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +4 -0
- package/dist/store/memory-sources.d.ts +59 -0
- package/dist/store/memory-sources.js +214 -0
- package/dist/store/session-recalled.d.ts +25 -0
- package/dist/store/session-recalled.js +163 -0
- package/dist/store/skill-store.d.ts +99 -0
- package/dist/store/skill-store.js +330 -0
- package/dist/store/sqlite.d.ts +5 -0
- package/dist/store/sqlite.js +30 -4
- package/dist/tools/index.d.ts +0 -13
- package/dist/tools/index.js +58 -7
- package/dist/tools/skills.d.ts +13 -0
- package/dist/tools/skills.js +130 -0
- package/dist/types.d.ts +6 -0
- package/dist/util/maturity.d.ts +27 -0
- package/dist/util/maturity.js +26 -0
- package/dist/wing-backfill.js +7 -4
- package/dsh.plugin.json +2 -2
- package/package.json +14 -14
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话"活召回集"存储(G-live):sessionKey → 本会话已注入、尚未被压缩覆盖的 L1 记录 id。
|
|
3
|
+
*
|
|
4
|
+
* 语义(对齐"当前会话召回后对 session 有影响的条目",且**旧会话重开也有效**):
|
|
5
|
+
* - 每次召回**实际注入**(recall.ts 注入成功点)按 **sessionKey**(=agent.session?.id ?? agent.id,
|
|
6
|
+
* 与 pill 的 sessionId、投影 header.id 同源)记入;
|
|
7
|
+
* - 上下文压缩/清空(agent/created source=compact|clear)按 sessionKey **reset**;
|
|
8
|
+
* - **写穿持久化到 `session-recalled.json`**(独立 sidecar,与 recall-dedupe 同款:串行原子写 +
|
|
9
|
+
* 未知版本/损坏只读降级、失败绝不抛进召回热路径)→ 重启/旧会话重开时 `init()` 载回,卡不空。
|
|
10
|
+
*
|
|
11
|
+
* 供只读端点 `dsh-memory/session-recalled` 消费(client 浮层 SessionRecalledArea 展示)。
|
|
12
|
+
* 不经 memorySlots 投影:那条路会把召回渲染成常驻槽位注册表,与召回的非常驻语义错位。
|
|
13
|
+
*/
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { errDetail } from '../util/filelog.js';
|
|
16
|
+
import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
|
|
17
|
+
import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
|
|
18
|
+
import { RECALL_DEDUPE_IDS_CAP, RECALL_DEDUPE_SESSION_CAP } from './recall-dedupe.js';
|
|
19
|
+
/** 条目过期清理(90 天,与 recall-dedupe 同量级)。 */
|
|
20
|
+
const PRUNE_MS = 90 * 24 * 3600_000;
|
|
21
|
+
export class SessionRecalledStore {
|
|
22
|
+
logger;
|
|
23
|
+
file;
|
|
24
|
+
entries = new Map();
|
|
25
|
+
degraded;
|
|
26
|
+
degradedLogged = false;
|
|
27
|
+
lastPersisted;
|
|
28
|
+
writeChain;
|
|
29
|
+
/** 脏检序号:每次真实变更 +1,投影 apply 据此 O(1) 判断是否重建。 */
|
|
30
|
+
rev = 0;
|
|
31
|
+
constructor(dataDir, logger) {
|
|
32
|
+
this.logger = logger;
|
|
33
|
+
this.file = path.join(dataDir, 'session-recalled.json');
|
|
34
|
+
this.writeChain = this.init();
|
|
35
|
+
}
|
|
36
|
+
revision() {
|
|
37
|
+
return this.rev;
|
|
38
|
+
}
|
|
39
|
+
async init() {
|
|
40
|
+
const r = await readJsonStrict(this.file, { expectedVersion: SESSION_RECALLED_FILE_VERSION });
|
|
41
|
+
let data;
|
|
42
|
+
if (r.ok) {
|
|
43
|
+
data = r.value;
|
|
44
|
+
}
|
|
45
|
+
else if (r.reason === 'missing') {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const lenient = await readJsonStrict(this.file);
|
|
50
|
+
if (!lenient.ok) {
|
|
51
|
+
this.degraded = lenient.reason;
|
|
52
|
+
this.logger?.warn(`[memory] 活召回集文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}):按空起步且不回写`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
data = lenient.value;
|
|
56
|
+
this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
|
|
57
|
+
this.logger?.warn(`[memory] 活召回集文件版本未知(${lenient.version ?? '无'}):按当前形状读取并只读降级(不回写)`);
|
|
58
|
+
}
|
|
59
|
+
this.lastPersisted = JSON.stringify(data);
|
|
60
|
+
if (!data?.sessions || typeof data.sessions !== 'object')
|
|
61
|
+
return;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const [sid, entry] of Object.entries(data.sessions)) {
|
|
64
|
+
if (!Array.isArray(entry?.recordIds))
|
|
65
|
+
continue;
|
|
66
|
+
if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
|
|
67
|
+
continue;
|
|
68
|
+
this.entries.set(sid, { ids: new Set(entry.recordIds), updatedAt: entry.updatedAt ?? now });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
|
|
72
|
+
add(sessionKey, recordIds) {
|
|
73
|
+
if (!sessionKey || recordIds.length === 0)
|
|
74
|
+
return;
|
|
75
|
+
let entry = this.entries.get(sessionKey);
|
|
76
|
+
if (!entry) {
|
|
77
|
+
entry = { ids: new Set(), updatedAt: 0 };
|
|
78
|
+
this.entries.set(sessionKey, entry);
|
|
79
|
+
}
|
|
80
|
+
for (const id of recordIds) {
|
|
81
|
+
entry.ids.add(id);
|
|
82
|
+
while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
|
|
83
|
+
const oldest = entry.ids.values().next().value;
|
|
84
|
+
if (oldest === undefined)
|
|
85
|
+
break;
|
|
86
|
+
entry.ids.delete(oldest);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
entry.updatedAt = Date.now();
|
|
90
|
+
this.rev++;
|
|
91
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
92
|
+
}
|
|
93
|
+
list(sessionKey) {
|
|
94
|
+
const entry = this.entries.get(sessionKey);
|
|
95
|
+
return entry ? [...entry.ids] : [];
|
|
96
|
+
}
|
|
97
|
+
/** 压缩/清空:注入内容已离开上下文,清该会话活召回集(写穿)。 */
|
|
98
|
+
reset(sessionKey) {
|
|
99
|
+
if (!this.entries.has(sessionKey))
|
|
100
|
+
return;
|
|
101
|
+
this.entries.delete(sessionKey);
|
|
102
|
+
this.rev++;
|
|
103
|
+
this.writeChain = this.writeChain.then(() => this.persist());
|
|
104
|
+
}
|
|
105
|
+
size() {
|
|
106
|
+
return this.entries.size;
|
|
107
|
+
}
|
|
108
|
+
/** 等待在途持久化写完成(测试/停机)。 */
|
|
109
|
+
flush() {
|
|
110
|
+
return this.writeChain;
|
|
111
|
+
}
|
|
112
|
+
async persist() {
|
|
113
|
+
if (this.degraded !== undefined) {
|
|
114
|
+
if (!this.degradedLogged) {
|
|
115
|
+
this.degradedLogged = true;
|
|
116
|
+
this.logger?.warn(`[memory] 活召回集处于只读降级(${this.degraded}),已停止回写`);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await ensureDir(path.dirname(this.file));
|
|
122
|
+
await rmwJson(this.file, async (cur) => {
|
|
123
|
+
const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
|
|
124
|
+
if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
|
|
125
|
+
throw new Error('活召回集文件已被其它进程更新,本次写入取消以避免覆盖');
|
|
126
|
+
}
|
|
127
|
+
const next = this.serialize();
|
|
128
|
+
this.lastPersisted = JSON.stringify(next);
|
|
129
|
+
return { next, result: undefined };
|
|
130
|
+
}, { logger: this.logger, purpose: 'session-recalled-rmw' });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
this.logger?.warn(`[memory] 活召回集持久化失败(降级内存态): ${errDetail(err)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
serialize() {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
for (const [sid, e] of this.entries) {
|
|
139
|
+
if (now - e.updatedAt > PRUNE_MS && e.updatedAt > 0)
|
|
140
|
+
this.entries.delete(sid);
|
|
141
|
+
}
|
|
142
|
+
while (this.entries.size > RECALL_DEDUPE_SESSION_CAP) {
|
|
143
|
+
let oldestKey;
|
|
144
|
+
let oldestAt = Infinity;
|
|
145
|
+
for (const [k, e] of this.entries) {
|
|
146
|
+
if (e.updatedAt < oldestAt) {
|
|
147
|
+
oldestAt = e.updatedAt;
|
|
148
|
+
oldestKey = k;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (oldestKey === undefined)
|
|
152
|
+
break;
|
|
153
|
+
this.entries.delete(oldestKey);
|
|
154
|
+
}
|
|
155
|
+
const sessions = {};
|
|
156
|
+
for (const [sid, e] of this.entries) {
|
|
157
|
+
if (e.ids.size === 0)
|
|
158
|
+
continue;
|
|
159
|
+
sessions[sid] = { recordIds: [...e.ids], updatedAt: e.updatedAt };
|
|
160
|
+
}
|
|
161
|
+
return { version: SESSION_RECALLED_FILE_VERSION, sessions };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { MemoryFamily, MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const SKILL_STORE_FILE_VERSION: 1;
|
|
3
|
+
export type SkillStatus = 'candidate' | 'active' | 'archived';
|
|
4
|
+
export type InjectionTier = 'full' | 'excerpt' | 'hint';
|
|
5
|
+
export interface SkillTrigger {
|
|
6
|
+
/** 归属 wing/hall(粗分类);缺省 = 不参与按域过滤。 */
|
|
7
|
+
hall?: string;
|
|
8
|
+
sceneName?: string;
|
|
9
|
+
keywords: string[];
|
|
10
|
+
workspaceId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Skill {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** 人可编辑正文;进 preserve 白名单,重建不覆盖。 */
|
|
16
|
+
body: string;
|
|
17
|
+
/** 机器聚合的 checklist 步骤;重建投影可覆盖。 */
|
|
18
|
+
steps: string[];
|
|
19
|
+
trigger: SkillTrigger;
|
|
20
|
+
injectionTier: InjectionTier;
|
|
21
|
+
status: SkillStatus;
|
|
22
|
+
pinned: boolean;
|
|
23
|
+
confidence: number;
|
|
24
|
+
family: MemoryFamily;
|
|
25
|
+
scope: 'global' | 'workspace';
|
|
26
|
+
/** 支撑该技能的证据记录 id(指向 l1_records.record_id,仅作溯源,不强约束外键)。 */
|
|
27
|
+
evidenceRecordIds: string[];
|
|
28
|
+
createdAt: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
lastUsedAt: string;
|
|
31
|
+
archivedReason?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SkillEvidence {
|
|
34
|
+
skillId: string;
|
|
35
|
+
runId: string;
|
|
36
|
+
recordId: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
at: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
}
|
|
41
|
+
export interface NewSkillInput {
|
|
42
|
+
id?: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
steps?: string[];
|
|
46
|
+
trigger?: Partial<SkillTrigger>;
|
|
47
|
+
injectionTier?: InjectionTier;
|
|
48
|
+
status?: SkillStatus;
|
|
49
|
+
family?: MemoryFamily;
|
|
50
|
+
scope?: 'global' | 'workspace';
|
|
51
|
+
evidenceRecordIds?: string[];
|
|
52
|
+
}
|
|
53
|
+
export declare class SkillStore {
|
|
54
|
+
private readonly logger?;
|
|
55
|
+
private readonly file;
|
|
56
|
+
private skills;
|
|
57
|
+
private evidence;
|
|
58
|
+
private rev;
|
|
59
|
+
private loaded;
|
|
60
|
+
private degraded;
|
|
61
|
+
private degradedLogged;
|
|
62
|
+
private writeChain;
|
|
63
|
+
private seq;
|
|
64
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
65
|
+
init(): Promise<void>;
|
|
66
|
+
private ensureLoaded;
|
|
67
|
+
list(): Skill[];
|
|
68
|
+
/** active 技能(注入候选)。 */
|
|
69
|
+
listActive(): Skill[];
|
|
70
|
+
get(id: string): Skill | undefined;
|
|
71
|
+
private newId;
|
|
72
|
+
/** 新建技能(幂等:显式传 id 且已存在 → 走 updateBody 语义由调用方决定,这里仅拒绝重复 id)。 */
|
|
73
|
+
create(input: NewSkillInput): Promise<Skill>;
|
|
74
|
+
/** 编辑人可读正文(body/title/steps/injectionTier/pinned);只动提供的字段。 */
|
|
75
|
+
update(id: string, patch: Partial<Pick<Skill, 'title' | 'body' | 'steps' | 'injectionTier' | 'pinned'>>): Promise<boolean>;
|
|
76
|
+
/** 追加一条证据(只追加);返回该技能当前的 distinct-session 数。 */
|
|
77
|
+
appendEvidence(ev: SkillEvidence): Promise<number>;
|
|
78
|
+
/** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
|
|
79
|
+
sessionCount(skillId: string): number;
|
|
80
|
+
/**
|
|
81
|
+
* 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
|
|
82
|
+
* 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
|
|
83
|
+
*/
|
|
84
|
+
tryPromote(skillId: string, minSessions: number, opts: {
|
|
85
|
+
allowPromote: boolean;
|
|
86
|
+
}): Promise<boolean>;
|
|
87
|
+
/** 记一次使用(刷新 lastUsedAt,供闲置归档与"只用才强化")。 */
|
|
88
|
+
touchUsed(skillId: string, at: string): Promise<boolean>;
|
|
89
|
+
/** 归档(active/candidate→archived);记录原因。走本表独立生命周期,不触碰 l1_records 退场。 */
|
|
90
|
+
archive(skillId: string, reason: string): Promise<boolean>;
|
|
91
|
+
/**
|
|
92
|
+
* 闲置归档:active 且未 pinned 且(now - lastUsedAt)> idleDays 的技能自动归档。
|
|
93
|
+
* 机械比较、0 LLM、不引入 timer(对齐 slots.expireDue 范式)。返回归档条数。
|
|
94
|
+
*/
|
|
95
|
+
archiveDue(now: number, idleDays: number): Promise<number>;
|
|
96
|
+
private patchOne;
|
|
97
|
+
/** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
|
|
98
|
+
private persist;
|
|
99
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能结晶存储(轨2,程序性记忆)。**默认关**(config.skills.enabled)。
|
|
3
|
+
*
|
|
4
|
+
* 设计裁定(对齐计划轨2 + 三视角审计):
|
|
5
|
+
* - 技能**不是 memory `type`**,而是 L1 之上的**可重建派生投影**——因此完全不碰
|
|
6
|
+
* `ADD_TYPES` 白名单与蒸馏 prompt 词表,从根上规避 S1(白名单静默降级)/S2(词表产不出)/
|
|
7
|
+
* S3(family 前缀误归族) 三处静默腐化;证据来源 = 现有 `work_method` L1 记录。
|
|
8
|
+
* - 落 **JSON sidecar**(仿 memory-sources/rooms-registry),不塞进 2691 行的 `sqlite.ts`:
|
|
9
|
+
* 技能条目量小、distinct-session 计数内存聚合即可;缺失=合法空表、损坏=只读降级不阻塞。
|
|
10
|
+
* - **`body`(人编辑)与 `steps`(机器聚合)分列**:机器侧可被重建投影覆盖,
|
|
11
|
+
* 人编辑侧永久保留(对齐 rebuild-preserve 的"非派生内容不可被重建抹掉"不变量)。
|
|
12
|
+
* - **只计数不晋升**(本轮):`skill_evidence` 记录 distinct session,晋升需
|
|
13
|
+
* `count(distinct session) >= minSessions` 且功能开;新装库无历史即空转,符合预期。
|
|
14
|
+
* - 归档 `archiveDue` 基于 `lastUsedAt` 闲置天数(机械比较,0 LLM、不引入 timer)。
|
|
15
|
+
*/
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
18
|
+
export const SKILL_STORE_FILE_VERSION = 1;
|
|
19
|
+
const SKILLS_CAP = 500;
|
|
20
|
+
const EVIDENCE_CAP = 20_000;
|
|
21
|
+
const TITLE_MAX = 120;
|
|
22
|
+
const BODY_MAX = 8000;
|
|
23
|
+
const STEP_MAX = 40;
|
|
24
|
+
const KEYWORDS_MAX = 16;
|
|
25
|
+
const TIER_SET = new Set(['full', 'excerpt', 'hint']);
|
|
26
|
+
const STATUS_SET = new Set(['candidate', 'active', 'archived']);
|
|
27
|
+
function str(v, def = '') {
|
|
28
|
+
return typeof v === 'string' ? v : def;
|
|
29
|
+
}
|
|
30
|
+
function normTier(v) {
|
|
31
|
+
return TIER_SET.has(v) ? v : 'excerpt';
|
|
32
|
+
}
|
|
33
|
+
function normStatus(v) {
|
|
34
|
+
return STATUS_SET.has(v) ? v : 'candidate';
|
|
35
|
+
}
|
|
36
|
+
/** 规整一条技能(读取侧 fail-soft:非法值归一到合法域,绝不因单条脏数据拖垮整表)。 */
|
|
37
|
+
function coerceSkill(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object')
|
|
39
|
+
return null;
|
|
40
|
+
const r = raw;
|
|
41
|
+
const id = str(r.id);
|
|
42
|
+
const title = str(r.title).slice(0, TITLE_MAX);
|
|
43
|
+
if (!id || !title)
|
|
44
|
+
return null;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
title,
|
|
48
|
+
body: str(r.body).slice(0, BODY_MAX),
|
|
49
|
+
steps: Array.isArray(r.steps) ? r.steps.filter((s) => typeof s === 'string').slice(0, STEP_MAX) : [],
|
|
50
|
+
trigger: {
|
|
51
|
+
hall: typeof r.trigger?.hall === 'string' ? str(r.trigger.hall) : undefined,
|
|
52
|
+
sceneName: typeof r.trigger?.sceneName === 'string' ? str(r.trigger.sceneName) : undefined,
|
|
53
|
+
keywords: Array.isArray(r.trigger?.keywords)
|
|
54
|
+
? r.trigger.keywords.filter((k) => typeof k === 'string' && k !== '').slice(0, KEYWORDS_MAX)
|
|
55
|
+
: [],
|
|
56
|
+
workspaceId: typeof r.trigger?.workspaceId === 'string' ? str(r.trigger.workspaceId) : undefined,
|
|
57
|
+
},
|
|
58
|
+
injectionTier: normTier(r.injectionTier),
|
|
59
|
+
status: normStatus(r.status),
|
|
60
|
+
pinned: r.pinned === true,
|
|
61
|
+
confidence: typeof r.confidence === 'number' && Number.isFinite(r.confidence) ? Math.max(0, Math.min(1, r.confidence)) : 0,
|
|
62
|
+
family: r.family === 'work' ? 'work' : 'chat',
|
|
63
|
+
scope: r.scope === 'workspace' ? 'workspace' : 'global',
|
|
64
|
+
evidenceRecordIds: Array.isArray(r.evidenceRecordIds) ? r.evidenceRecordIds.filter((x) => typeof x === 'string').slice(0, 200) : [],
|
|
65
|
+
createdAt: str(r.createdAt),
|
|
66
|
+
updatedAt: str(r.updatedAt),
|
|
67
|
+
lastUsedAt: str(r.lastUsedAt),
|
|
68
|
+
archivedReason: typeof r.archivedReason === 'string' ? r.archivedReason : undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export class SkillStore {
|
|
72
|
+
logger;
|
|
73
|
+
file;
|
|
74
|
+
skills = [];
|
|
75
|
+
evidence = [];
|
|
76
|
+
rev = 0;
|
|
77
|
+
loaded = false;
|
|
78
|
+
degraded;
|
|
79
|
+
degradedLogged = false;
|
|
80
|
+
writeChain = Promise.resolve();
|
|
81
|
+
seq = 0;
|
|
82
|
+
constructor(dataDir, logger) {
|
|
83
|
+
this.logger = logger;
|
|
84
|
+
this.file = path.join(dataDir, 'skills.json');
|
|
85
|
+
}
|
|
86
|
+
async init() {
|
|
87
|
+
await ensureDir(path.dirname(this.file));
|
|
88
|
+
const read = await readJsonStrict(this.file);
|
|
89
|
+
if (!read.ok) {
|
|
90
|
+
if (read.reason !== 'missing') {
|
|
91
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
92
|
+
this.logger?.warn(`[memory] 技能存储只读降级(内存态照常,停止回写): ${this.degraded}`);
|
|
93
|
+
}
|
|
94
|
+
this.loaded = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (read.value.version !== SKILL_STORE_FILE_VERSION) {
|
|
98
|
+
this.logger?.warn(`[memory] 技能存储版本 ${String(read.value.version)} 未知,按当前形状读取`);
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(read.value.skills)) {
|
|
101
|
+
for (const raw of read.value.skills) {
|
|
102
|
+
const s = coerceSkill(raw);
|
|
103
|
+
if (s)
|
|
104
|
+
this.skills.push(s);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(read.value.evidence)) {
|
|
108
|
+
for (const e of read.value.evidence) {
|
|
109
|
+
if (e && typeof e.skillId === 'string' && typeof e.sessionId === 'string')
|
|
110
|
+
this.evidence.push(e);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.rev = typeof read.value.rev === 'number' ? read.value.rev : 0;
|
|
114
|
+
// 序号续接:避免重启后铸出重复 skill id。
|
|
115
|
+
this.seq = this.skills.length;
|
|
116
|
+
this.loaded = true;
|
|
117
|
+
}
|
|
118
|
+
ensureLoaded() {
|
|
119
|
+
if (!this.loaded)
|
|
120
|
+
throw new Error('技能存储未初始化(先 await init())');
|
|
121
|
+
}
|
|
122
|
+
list() {
|
|
123
|
+
this.ensureLoaded();
|
|
124
|
+
return this.skills.map((s) => ({ ...s, steps: [...s.steps], trigger: { ...s.trigger, keywords: [...s.trigger.keywords] }, evidenceRecordIds: [...s.evidenceRecordIds] }));
|
|
125
|
+
}
|
|
126
|
+
/** active 技能(注入候选)。 */
|
|
127
|
+
listActive() {
|
|
128
|
+
return this.list().filter((s) => s.status === 'active');
|
|
129
|
+
}
|
|
130
|
+
get(id) {
|
|
131
|
+
this.ensureLoaded();
|
|
132
|
+
const s = this.skills.find((x) => x.id === id);
|
|
133
|
+
return s ? { ...s, steps: [...s.steps], trigger: { ...s.trigger, keywords: [...s.trigger.keywords] }, evidenceRecordIds: [...s.evidenceRecordIds] } : undefined;
|
|
134
|
+
}
|
|
135
|
+
newId() {
|
|
136
|
+
this.seq++;
|
|
137
|
+
return 'sk-' + Date.now().toString(36) + '-' + this.seq.toString(36);
|
|
138
|
+
}
|
|
139
|
+
/** 新建技能(幂等:显式传 id 且已存在 → 走 updateBody 语义由调用方决定,这里仅拒绝重复 id)。 */
|
|
140
|
+
async create(input) {
|
|
141
|
+
this.ensureLoaded();
|
|
142
|
+
if (this.skills.length >= SKILLS_CAP)
|
|
143
|
+
throw new Error(`技能数已达上限(${SKILLS_CAP}),请归档部分`);
|
|
144
|
+
const id = (input.id ?? '').trim() || this.newId();
|
|
145
|
+
if (this.skills.some((s) => s.id === id))
|
|
146
|
+
throw new Error(`技能 id 已存在: ${id}`);
|
|
147
|
+
const title = String(input.title ?? '').trim().slice(0, TITLE_MAX);
|
|
148
|
+
if (!title)
|
|
149
|
+
throw new Error('技能 title 不能为空');
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const skill = {
|
|
152
|
+
id,
|
|
153
|
+
title,
|
|
154
|
+
body: String(input.body ?? '').slice(0, BODY_MAX),
|
|
155
|
+
steps: (input.steps ?? []).filter((x) => typeof x === 'string').slice(0, STEP_MAX),
|
|
156
|
+
trigger: {
|
|
157
|
+
hall: input.trigger?.hall?.trim() || undefined,
|
|
158
|
+
sceneName: input.trigger?.sceneName?.trim() || undefined,
|
|
159
|
+
keywords: (input.trigger?.keywords ?? []).filter((k) => k).slice(0, KEYWORDS_MAX),
|
|
160
|
+
workspaceId: input.trigger?.workspaceId || undefined,
|
|
161
|
+
},
|
|
162
|
+
injectionTier: normTier(input.injectionTier),
|
|
163
|
+
status: normStatus(input.status),
|
|
164
|
+
pinned: false,
|
|
165
|
+
confidence: 0,
|
|
166
|
+
family: input.family === 'chat' ? 'chat' : 'work',
|
|
167
|
+
scope: input.scope === 'workspace' ? 'workspace' : 'global',
|
|
168
|
+
evidenceRecordIds: (input.evidenceRecordIds ?? []).slice(0, 200),
|
|
169
|
+
createdAt: now,
|
|
170
|
+
updatedAt: now,
|
|
171
|
+
lastUsedAt: '',
|
|
172
|
+
};
|
|
173
|
+
this.skills.push(skill);
|
|
174
|
+
this.rev++;
|
|
175
|
+
await this.persist();
|
|
176
|
+
return { ...skill };
|
|
177
|
+
}
|
|
178
|
+
/** 编辑人可读正文(body/title/steps/injectionTier/pinned);只动提供的字段。 */
|
|
179
|
+
async update(id, patch) {
|
|
180
|
+
this.ensureLoaded();
|
|
181
|
+
const s = this.skills.find((x) => x.id === id);
|
|
182
|
+
if (!s)
|
|
183
|
+
return false;
|
|
184
|
+
let changed = false;
|
|
185
|
+
if (typeof patch.title === 'string' && patch.title.trim()) {
|
|
186
|
+
s.title = patch.title.trim().slice(0, TITLE_MAX);
|
|
187
|
+
changed = true;
|
|
188
|
+
}
|
|
189
|
+
if (typeof patch.body === 'string') {
|
|
190
|
+
s.body = patch.body.slice(0, BODY_MAX);
|
|
191
|
+
changed = true;
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(patch.steps)) {
|
|
194
|
+
s.steps = patch.steps.filter((x) => typeof x === 'string').slice(0, STEP_MAX);
|
|
195
|
+
changed = true;
|
|
196
|
+
}
|
|
197
|
+
if (patch.injectionTier !== undefined && TIER_SET.has(patch.injectionTier)) {
|
|
198
|
+
s.injectionTier = patch.injectionTier;
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
if (patch.pinned !== undefined && patch.pinned !== s.pinned) {
|
|
202
|
+
s.pinned = patch.pinned;
|
|
203
|
+
changed = true;
|
|
204
|
+
}
|
|
205
|
+
if (!changed)
|
|
206
|
+
return false;
|
|
207
|
+
s.updatedAt = new Date().toISOString();
|
|
208
|
+
this.rev++;
|
|
209
|
+
await this.persist();
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
/** 追加一条证据(只追加);返回该技能当前的 distinct-session 数。 */
|
|
213
|
+
async appendEvidence(ev) {
|
|
214
|
+
this.ensureLoaded();
|
|
215
|
+
if (!this.skills.some((s) => s.id === ev.skillId))
|
|
216
|
+
return 0;
|
|
217
|
+
this.evidence.push(ev);
|
|
218
|
+
if (this.evidence.length > EVIDENCE_CAP)
|
|
219
|
+
this.evidence.splice(0, this.evidence.length - EVIDENCE_CAP);
|
|
220
|
+
this.rev++;
|
|
221
|
+
await this.persist();
|
|
222
|
+
return this.sessionCount(ev.skillId);
|
|
223
|
+
}
|
|
224
|
+
/** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
|
|
225
|
+
sessionCount(skillId) {
|
|
226
|
+
this.ensureLoaded();
|
|
227
|
+
const s = new Set();
|
|
228
|
+
for (const e of this.evidence)
|
|
229
|
+
if (e.skillId === skillId && e.sessionId)
|
|
230
|
+
s.add(e.sessionId);
|
|
231
|
+
return s.size;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
|
|
235
|
+
* 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
|
|
236
|
+
*/
|
|
237
|
+
async tryPromote(skillId, minSessions, opts) {
|
|
238
|
+
this.ensureLoaded();
|
|
239
|
+
if (!opts.allowPromote)
|
|
240
|
+
return false;
|
|
241
|
+
const s = this.skills.find((x) => x.id === skillId);
|
|
242
|
+
if (!s || s.status !== 'candidate')
|
|
243
|
+
return false;
|
|
244
|
+
if (this.sessionCount(skillId) < minSessions)
|
|
245
|
+
return false;
|
|
246
|
+
s.status = 'active';
|
|
247
|
+
s.updatedAt = new Date().toISOString();
|
|
248
|
+
this.rev++;
|
|
249
|
+
await this.persist();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/** 记一次使用(刷新 lastUsedAt,供闲置归档与"只用才强化")。 */
|
|
253
|
+
async touchUsed(skillId, at) {
|
|
254
|
+
return this.patchOne(skillId, (s) => {
|
|
255
|
+
s.lastUsedAt = at;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** 归档(active/candidate→archived);记录原因。走本表独立生命周期,不触碰 l1_records 退场。 */
|
|
259
|
+
async archive(skillId, reason) {
|
|
260
|
+
this.ensureLoaded();
|
|
261
|
+
const s = this.skills.find((x) => x.id === skillId);
|
|
262
|
+
if (!s || s.status === 'archived')
|
|
263
|
+
return false;
|
|
264
|
+
s.status = 'archived';
|
|
265
|
+
s.archivedReason = reason;
|
|
266
|
+
s.updatedAt = new Date().toISOString();
|
|
267
|
+
this.rev++;
|
|
268
|
+
await this.persist();
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* 闲置归档:active 且未 pinned 且(now - lastUsedAt)> idleDays 的技能自动归档。
|
|
273
|
+
* 机械比较、0 LLM、不引入 timer(对齐 slots.expireDue 范式)。返回归档条数。
|
|
274
|
+
*/
|
|
275
|
+
async archiveDue(now, idleDays) {
|
|
276
|
+
this.ensureLoaded();
|
|
277
|
+
if (idleDays <= 0)
|
|
278
|
+
return 0;
|
|
279
|
+
const cutoff = now - idleDays * 86_400_000;
|
|
280
|
+
let n = 0;
|
|
281
|
+
for (const s of this.skills) {
|
|
282
|
+
if (s.status !== 'active' || s.pinned)
|
|
283
|
+
continue;
|
|
284
|
+
const last = s.lastUsedAt ? Date.parse(s.lastUsedAt) : Date.parse(s.updatedAt || s.createdAt);
|
|
285
|
+
if (Number.isFinite(last) && last < cutoff) {
|
|
286
|
+
s.status = 'archived';
|
|
287
|
+
s.archivedReason = `idle>${idleDays}d`;
|
|
288
|
+
s.updatedAt = new Date(now).toISOString();
|
|
289
|
+
n++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (n) {
|
|
293
|
+
this.rev++;
|
|
294
|
+
await this.persist();
|
|
295
|
+
}
|
|
296
|
+
return n;
|
|
297
|
+
}
|
|
298
|
+
async patchOne(id, mut) {
|
|
299
|
+
this.ensureLoaded();
|
|
300
|
+
const s = this.skills.find((x) => x.id === id);
|
|
301
|
+
if (!s)
|
|
302
|
+
return false;
|
|
303
|
+
mut(s);
|
|
304
|
+
s.updatedAt = new Date().toISOString();
|
|
305
|
+
this.rev++;
|
|
306
|
+
await this.persist();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
/** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
|
|
310
|
+
async persist() {
|
|
311
|
+
if (this.degraded)
|
|
312
|
+
return;
|
|
313
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
314
|
+
const data = { version: SKILL_STORE_FILE_VERSION, rev: this.rev + 1, skills: this.skills, evidence: this.evidence };
|
|
315
|
+
try {
|
|
316
|
+
await ensureDir(path.dirname(this.file));
|
|
317
|
+
await atomicWriteText(this.file, JSON.stringify(data, null, 2));
|
|
318
|
+
this.rev = data.rev;
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
322
|
+
if (!this.degradedLogged) {
|
|
323
|
+
this.degradedLogged = true;
|
|
324
|
+
this.logger?.warn(`[memory] 技能存储写入失败,只读降级: ${this.degraded}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
await this.writeChain;
|
|
329
|
+
}
|
|
330
|
+
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -198,6 +198,11 @@ export declare class MemoryDb {
|
|
|
198
198
|
*/
|
|
199
199
|
clearL1(): boolean;
|
|
200
200
|
countL1(): number;
|
|
201
|
+
/**
|
|
202
|
+
* 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
|
|
203
|
+
* 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
|
|
204
|
+
*/
|
|
205
|
+
findRecordIdByExtKey(extKey: string): string | undefined;
|
|
201
206
|
/**
|
|
202
207
|
* 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
|
|
203
208
|
*
|
package/dist/store/sqlite.js
CHANGED
|
@@ -318,6 +318,14 @@ export class MemoryDb {
|
|
|
318
318
|
this.db.exec("ALTER TABLE l1_records ADD COLUMN applicability TEXT NOT NULL DEFAULT ''");
|
|
319
319
|
this.logger?.info(`${TAG} l1_records 补 repo 归属列(DEFAULT 即标注,存量零回填)`);
|
|
320
320
|
}
|
|
321
|
+
// ── 外部导入幂等键(轨3-P0):ext_key='' 即内部记录(不参与去重),DEFAULT '' 存量零搬运。
|
|
322
|
+
// 部分唯一索引只约束非空值——多条内部记录 ext_key 同为 '' 互不冲突;仅当外部导入的
|
|
323
|
+
// 同一 (来源, 稳定身份) 重复导入才被结构性拒绝(写侧 pre-check + 索引双保险)。
|
|
324
|
+
if (!this.hasColumn('l1_records', 'ext_key')) {
|
|
325
|
+
this.db.exec("ALTER TABLE l1_records ADD COLUMN ext_key TEXT NOT NULL DEFAULT ''");
|
|
326
|
+
this.logger?.info(`${TAG} l1_records 补 ext_key 列(外部导入幂等键)`);
|
|
327
|
+
}
|
|
328
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS l1_records_ext_key_uidx ON l1_records(ext_key) WHERE ext_key <> ''");
|
|
321
329
|
// L2 场景重聚类作业队列(治理 W3 T3.10;ruminate 空闲档消费,每轮至多 1 作业)。
|
|
322
330
|
// source 区分触发来源('demote'|'repo-change'),便于诊断与重入队语义区分。
|
|
323
331
|
this.db.exec(`
|
|
@@ -434,8 +442,8 @@ export class MemoryDb {
|
|
|
434
442
|
record_id, content, type, priority, scene_name, session_id, version,
|
|
435
443
|
timestamp_str, timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
|
|
436
444
|
valid_from, valid_to, persistence, scope, workspace_id,
|
|
437
|
-
repo_key_name, repo_key_owner, applicability
|
|
438
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
445
|
+
repo_key_name, repo_key_owner, applicability, ext_key
|
|
446
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
439
447
|
ON CONFLICT(record_id) DO UPDATE SET
|
|
440
448
|
content=excluded.content,
|
|
441
449
|
type=excluded.type,
|
|
@@ -455,7 +463,8 @@ export class MemoryDb {
|
|
|
455
463
|
workspace_id=excluded.workspace_id,
|
|
456
464
|
repo_key_name=excluded.repo_key_name,
|
|
457
465
|
repo_key_owner=excluded.repo_key_owner,
|
|
458
|
-
applicability=excluded.applicability
|
|
466
|
+
applicability=excluded.applicability,
|
|
467
|
+
ext_key=excluded.ext_key
|
|
459
468
|
`);
|
|
460
469
|
this.stmtGetL1 = this.db.prepare(`
|
|
461
470
|
SELECT record_id, content, type, priority, scene_name, version, timestamp_str,
|
|
@@ -907,7 +916,7 @@ export class MemoryDb {
|
|
|
907
916
|
// 旧版导入都会经这里写回记录,若照常重建 FTS/向量行,一条带退场标记的记录
|
|
908
917
|
// 会**悄悄回到检索结果里**(而检索侧刻意不看 valid_to,正是为了零漂移)。
|
|
909
918
|
const retiredNow = record.validTo !== undefined || readSupersedeMarker(record.metadata) !== undefined;
|
|
910
|
-
this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family, toIso(record.validFrom), toIso(record.validTo), normPersistence(record.persistence) ?? '', scope, workspaceId, record.repoKeyName ?? '', record.repoKeyOwner ?? '', normApplicability(record.applicability));
|
|
919
|
+
this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family, toIso(record.validFrom), toIso(record.validTo), normPersistence(record.persistence) ?? '', scope, workspaceId, record.repoKeyName ?? '', record.repoKeyOwner ?? '', normApplicability(record.applicability), record.extKey ?? '');
|
|
911
920
|
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)。
|
|
912
921
|
// 已退场则**只删不插**(见上方 retiredNow 的说明)。
|
|
913
922
|
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
@@ -1134,6 +1143,23 @@ export class MemoryDb {
|
|
|
1134
1143
|
return 0;
|
|
1135
1144
|
}
|
|
1136
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
|
|
1148
|
+
* 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
|
|
1149
|
+
*/
|
|
1150
|
+
findRecordIdByExtKey(extKey) {
|
|
1151
|
+
if (this.degraded || !extKey)
|
|
1152
|
+
return undefined;
|
|
1153
|
+
try {
|
|
1154
|
+
const row = this.db
|
|
1155
|
+
.prepare("SELECT record_id FROM l1_records WHERE ext_key = ? AND ext_key <> '' LIMIT 1")
|
|
1156
|
+
.get(extKey);
|
|
1157
|
+
return row?.record_id;
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1137
1163
|
/**
|
|
1138
1164
|
* 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
|
|
1139
1165
|
*
|