@stardustlc/dsh-dream 0.4.1 → 0.5.1
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 +20 -0
- package/README.en.md +30 -3
- package/README.md +33 -3
- package/lib/client.js +331 -11
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/journal.d.ts +15 -0
- package/lib/journal.js +32 -11
- package/lib/knowledge-store.d.ts +172 -0
- package/lib/knowledge-store.js +1453 -0
- package/lib/knowledge.d.ts +179 -0
- package/lib/knowledge.js +378 -0
- package/lib/retrieval.d.ts +98 -0
- package/lib/retrieval.js +515 -0
- package/lib/tools.js +522 -13
- package/lib/web.d.ts +46 -18
- package/lib/web.js +163 -39
- package/package.json +1 -1
- package/skills/dream-protocol/SKILL.md +103 -29
package/lib/tools.js
CHANGED
|
@@ -1,29 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 面向模型的做梦工具:dream_digest / dream_save / dream_journal / dream_recall / dream_bridge / dream_health
|
|
3
|
+
* 与 M1 新增的 dream_learn / dream_context / dream_review。
|
|
3
4
|
*
|
|
4
5
|
* @module dsh-dream/tools
|
|
5
6
|
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
6
8
|
import { existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
7
10
|
import { maskSecrets } from './mask.js';
|
|
8
11
|
import { dreamStats, readDreams, saveDream, searchDreams } from './journal.js';
|
|
9
12
|
import { bridgeDreams } from './bridge.js';
|
|
10
13
|
import { digestSessionFile, listSessionFiles } from './sessions.js';
|
|
11
|
-
|
|
14
|
+
import { KnowledgeError, lessonFingerprint, } from './knowledge.js';
|
|
15
|
+
import { KnowledgeStore } from './knowledge-store.js';
|
|
16
|
+
import { queryScopeLabel, resolveRetrievalBudget, retrieveLessons, retrievedLessonChars, } from './retrieval.js';
|
|
17
|
+
/** 编译一个值 schema 节点;支持 enum、对象数组 items 与嵌套对象(宿主 schema 子集要求对象显式 additionalProperties)。 */
|
|
18
|
+
function compileValueNode(prop) {
|
|
19
|
+
const node = {};
|
|
20
|
+
if (typeof prop.description === 'string')
|
|
21
|
+
node.description = prop.description;
|
|
22
|
+
if (Array.isArray(prop.oneOf) && prop.oneOf.length >= 2) {
|
|
23
|
+
node.oneOf = prop.oneOf.map((branch) => compileValueNode(branch));
|
|
24
|
+
return node;
|
|
25
|
+
}
|
|
26
|
+
if (typeof prop.type === 'string')
|
|
27
|
+
node.type = prop.type;
|
|
28
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0)
|
|
29
|
+
node.enum = prop.enum.slice();
|
|
30
|
+
if (prop.type === 'array') {
|
|
31
|
+
node.items = typeof prop.items === 'object' && prop.items !== null ? compileItemsNode(prop.items) : { type: 'string' };
|
|
32
|
+
}
|
|
33
|
+
else if (prop.type === 'object') {
|
|
34
|
+
const nested = compilePropertyMap(typeof prop.properties === 'object' && prop.properties !== null ? prop.properties : {});
|
|
35
|
+
node.properties = nested.properties;
|
|
36
|
+
node.additionalProperties = prop.additionalProperties === false ? false : true;
|
|
37
|
+
const required = mergeRequired(nested.properties, nested.required, prop.required);
|
|
38
|
+
if (required !== undefined)
|
|
39
|
+
node.required = required;
|
|
40
|
+
}
|
|
41
|
+
return node;
|
|
42
|
+
}
|
|
43
|
+
/** 编译数组 items;对象元素展开为 properties/required/additionalProperties。 */
|
|
44
|
+
function compileItemsNode(items) {
|
|
45
|
+
if (items.type === 'object') {
|
|
46
|
+
const nested = compilePropertyMap(typeof items.properties === 'object' && items.properties !== null ? items.properties : {});
|
|
47
|
+
const required = mergeRequired(nested.properties, nested.required, items.required);
|
|
48
|
+
return {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: nested.properties,
|
|
51
|
+
additionalProperties: items.additionalProperties === false ? false : true,
|
|
52
|
+
...(required !== undefined ? { required } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const node = { type: typeof items.type === 'string' ? items.type : 'string' };
|
|
56
|
+
if (typeof items.description === 'string')
|
|
57
|
+
node.description = items.description;
|
|
58
|
+
if (Array.isArray(items.enum) && items.enum.length > 0)
|
|
59
|
+
node.enum = items.enum.slice();
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
/** 合并嵌套对象 required:既接受 properties 里的 required: true,也接受节点上的 required: string[]。 */
|
|
63
|
+
function mergeRequired(properties, fromProperties, explicit) {
|
|
64
|
+
const required = fromProperties === undefined ? [] : [...fromProperties];
|
|
65
|
+
if (Array.isArray(explicit)) {
|
|
66
|
+
for (const key of explicit) {
|
|
67
|
+
if (typeof key === 'string' && Object.hasOwn(properties, key) && !required.includes(key))
|
|
68
|
+
required.push(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return required.length > 0 ? required : undefined;
|
|
72
|
+
}
|
|
73
|
+
/** 编译属性表;支持 per-property required: true 与顶层 required: string[] 两种写法。 */
|
|
74
|
+
function compilePropertyMap(spec) {
|
|
12
75
|
const properties = {};
|
|
13
76
|
const required = [];
|
|
77
|
+
const structural = new Set(['type', 'properties', 'additionalProperties', 'required', 'items', 'enum', 'description', 'oneOf']);
|
|
78
|
+
const explicitRequired = Array.isArray(spec.required)
|
|
79
|
+
? spec.required.filter((key) => typeof key === 'string')
|
|
80
|
+
: [];
|
|
14
81
|
for (const [key, prop] of Object.entries(spec)) {
|
|
15
|
-
if (
|
|
82
|
+
if (structural.has(key))
|
|
83
|
+
continue;
|
|
84
|
+
if (typeof prop !== 'object' || prop === null)
|
|
85
|
+
continue;
|
|
86
|
+
if (prop.required === true && !required.includes(key))
|
|
87
|
+
required.push(key);
|
|
88
|
+
properties[key] = compileValueNode(prop);
|
|
89
|
+
}
|
|
90
|
+
for (const key of explicitRequired) {
|
|
91
|
+
if (key in properties && !required.includes(key))
|
|
16
92
|
required.push(key);
|
|
17
|
-
const node = {};
|
|
18
|
-
if (typeof prop?.type === 'string')
|
|
19
|
-
node.type = prop.type;
|
|
20
|
-
if (typeof prop?.description === 'string')
|
|
21
|
-
node.description = prop.description;
|
|
22
|
-
if (prop?.type === 'array' && typeof prop.items === 'object')
|
|
23
|
-
node.items = { type: 'string' };
|
|
24
|
-
properties[key] = node;
|
|
25
93
|
}
|
|
26
|
-
return
|
|
94
|
+
return required.length > 0 ? { properties, required } : { properties };
|
|
95
|
+
}
|
|
96
|
+
/** 编译工具 parameters;根类型固定为对象。 */
|
|
97
|
+
function compileParameters(spec) {
|
|
98
|
+
const compiled = compilePropertyMap(spec);
|
|
99
|
+
return { type: 'object', properties: compiled.properties, ...(compiled.required !== undefined ? { required: compiled.required } : {}) };
|
|
27
100
|
}
|
|
28
101
|
function asRecord(value) {
|
|
29
102
|
return typeof value === 'object' && value !== null ? value : {};
|
|
@@ -47,6 +120,101 @@ function stringArray(args, key) {
|
|
|
47
120
|
function clip(text, max) {
|
|
48
121
|
return text.length > max ? text.slice(0, max) + '…(已截断)' : text;
|
|
49
122
|
}
|
|
123
|
+
function maskIfNeeded(cfg, text) {
|
|
124
|
+
return cfg.maskSecrets ? maskSecrets(text) : text;
|
|
125
|
+
}
|
|
126
|
+
function knowledgeDirOf(cfg) {
|
|
127
|
+
return join(cfg.journalDir, 'knowledge');
|
|
128
|
+
}
|
|
129
|
+
function knowledgeStoreOf(cfg) {
|
|
130
|
+
return new KnowledgeStore(knowledgeDirOf(cfg), { maskSecrets: cfg.maskSecrets });
|
|
131
|
+
}
|
|
132
|
+
function sha256Short(value) {
|
|
133
|
+
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32);
|
|
134
|
+
}
|
|
135
|
+
const LESSON_KINDS = ['preference', 'procedure', 'pitfall', 'fact'];
|
|
136
|
+
function parseLessonKind(args) {
|
|
137
|
+
const value = optionalString(args, 'kind');
|
|
138
|
+
if (value === undefined || !LESSON_KINDS.includes(value)) {
|
|
139
|
+
throw new Error('kind 必须是 ' + LESSON_KINDS.join(' / ') + '。');
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
/** 解析模型传入的证据数组;projectId 由经验范围补齐,便于以后按项目核对。 */
|
|
144
|
+
function parseEvidenceArgs(args, key, projectId) {
|
|
145
|
+
const raw = args[key];
|
|
146
|
+
if (raw === undefined || raw === null)
|
|
147
|
+
return [];
|
|
148
|
+
if (!Array.isArray(raw))
|
|
149
|
+
throw new Error('参数 ' + key + ' 必须是对象数组。');
|
|
150
|
+
const out = [];
|
|
151
|
+
raw.forEach((value, index) => {
|
|
152
|
+
const item = asRecord(value);
|
|
153
|
+
const kind = optionalString(item, 'kind');
|
|
154
|
+
if (kind !== 'session' && kind !== 'user-correction' && kind !== 'local-artifact') {
|
|
155
|
+
throw new Error(key + '[' + index + '].kind 必须是 session / user-correction / local-artifact。');
|
|
156
|
+
}
|
|
157
|
+
const summary = requiredString(item, 'summary', key + '[' + index + '].summary');
|
|
158
|
+
const verification = optionalString(item, 'verification');
|
|
159
|
+
if (verification !== 'read' && verification !== 'claimed') {
|
|
160
|
+
throw new Error(key + '[' + index + '].verification 必须是 read / claimed。');
|
|
161
|
+
}
|
|
162
|
+
const sessionId = optionalString(item, 'sessionId');
|
|
163
|
+
const recordSeqRaw = item.recordSeq;
|
|
164
|
+
let recordSeq;
|
|
165
|
+
if (typeof recordSeqRaw === 'number' && Number.isInteger(recordSeqRaw) && recordSeqRaw >= 0)
|
|
166
|
+
recordSeq = recordSeqRaw;
|
|
167
|
+
else if (typeof recordSeqRaw === 'string' && recordSeqRaw.trim() !== '')
|
|
168
|
+
recordSeq = recordSeqRaw.trim();
|
|
169
|
+
const entry = { kind, summary, verification };
|
|
170
|
+
if (sessionId !== undefined)
|
|
171
|
+
entry.sessionId = sessionId;
|
|
172
|
+
if (recordSeq !== undefined)
|
|
173
|
+
entry.recordSeq = recordSeq;
|
|
174
|
+
if (projectId !== undefined)
|
|
175
|
+
entry.projectId = projectId;
|
|
176
|
+
out.push(entry);
|
|
177
|
+
});
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function parseApplicability(args, key) {
|
|
181
|
+
const raw = args[key];
|
|
182
|
+
if (raw === undefined || raw === null)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (!Array.isArray(raw))
|
|
185
|
+
throw new Error('参数 ' + key + ' 必须是对象数组。');
|
|
186
|
+
const out = [];
|
|
187
|
+
raw.forEach((value) => {
|
|
188
|
+
const item = asRecord(value);
|
|
189
|
+
const entry = {};
|
|
190
|
+
const pkg = optionalString(item, 'package');
|
|
191
|
+
const versions = optionalString(item, 'versions');
|
|
192
|
+
const platform = optionalString(item, 'platform');
|
|
193
|
+
if (pkg !== undefined)
|
|
194
|
+
entry.package = pkg;
|
|
195
|
+
if (versions !== undefined)
|
|
196
|
+
entry.versions = versions;
|
|
197
|
+
if (platform !== undefined)
|
|
198
|
+
entry.platform = platform;
|
|
199
|
+
if (Object.keys(entry).length > 0)
|
|
200
|
+
out.push(entry);
|
|
201
|
+
});
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** 证据对象数组的 schema 片段(dream_learn / dream_review 共用形状,各自新建避免引用共享)。 */
|
|
205
|
+
function evidenceItemsSpec() {
|
|
206
|
+
return {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {
|
|
209
|
+
kind: { type: 'string', enum: ['session', 'user-correction', 'local-artifact'], description: '证据来源类型。' },
|
|
210
|
+
sessionId: { type: 'string', description: '会话 ID(session 证据建议提供)。' },
|
|
211
|
+
recordSeq: { oneOf: [{ type: 'string' }, { type: 'integer' }], description: '记录序号(数字或字符串)。' },
|
|
212
|
+
summary: { type: 'string', description: '已脱敏的一句话摘要。' },
|
|
213
|
+
verification: { type: 'string', enum: ['read', 'claimed'], description: 'read=插件读到原记录;claimed=仅模型声称。' },
|
|
214
|
+
},
|
|
215
|
+
required: ['kind', 'summary', 'verification'],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
50
218
|
const baseSchema = { type: 'object', additionalProperties: true };
|
|
51
219
|
/** 构建六个做梦工具。 */
|
|
52
220
|
export function buildDreamTools(config) {
|
|
@@ -264,7 +432,348 @@ export function buildDreamTools(config) {
|
|
|
264
432
|
},
|
|
265
433
|
timeoutMs: 15000,
|
|
266
434
|
};
|
|
267
|
-
|
|
435
|
+
const dreamLearn = {
|
|
436
|
+
name: 'dream_learn',
|
|
437
|
+
description: '学梦:把一条可复用经验连同证据提交为候选。必须写清 title(一句话结论)、action(可执行动作)、when(适用条件)与 exceptions(例外);evidence 用 sessionId+recordSeq 或 sourceHash 定位来源,verification=read 表示插件读到原记录、claimed 表示仅模型声称(不计入独立支持)。无证据的经验只以 candidate 保存,不会当作已证实结论注入。模型不得伪造用户采纳。',
|
|
438
|
+
parameters: compileParameters({
|
|
439
|
+
kind: { type: 'string', required: true, enum: ['preference', 'procedure', 'pitfall', 'fact'], description: '经验类别。' },
|
|
440
|
+
title: { type: 'string', required: true, description: '一句话结论;脱离 when 不得单独使用。' },
|
|
441
|
+
action: { type: 'string', required: true, description: '可执行动作(动词开头)。' },
|
|
442
|
+
when: { type: 'string', required: true, description: '适用条件(何时该用);检索时不可截断。' },
|
|
443
|
+
exceptions: { type: 'array', items: { type: 'string' }, description: '例外情形(可选)。' },
|
|
444
|
+
projectId: { type: 'string', description: '项目 ID(可选)。' },
|
|
445
|
+
workspaceRoot: { type: 'string', description: '项目工作区根目录(可选)。' },
|
|
446
|
+
global: { type: 'boolean', description: '是否为显式全局经验(可选,默认 false)。' },
|
|
447
|
+
applicability: {
|
|
448
|
+
type: 'array',
|
|
449
|
+
items: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
package: { type: 'string', description: '依赖 / 工具名。' },
|
|
453
|
+
versions: { type: 'string', description: '适用版本范围(如 ^9.0.5)。' },
|
|
454
|
+
platform: { type: 'string', description: '平台。' },
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
description: '适用范围(可选)。',
|
|
458
|
+
},
|
|
459
|
+
evidence: { type: 'array', items: evidenceItemsSpec(), description: '证据数组(可选,缺省为空数组;无证据时只能保存为 candidate)。' },
|
|
460
|
+
}),
|
|
461
|
+
output: {
|
|
462
|
+
schema: baseSchema,
|
|
463
|
+
render: (_args, value) => {
|
|
464
|
+
const rec = asRecord(value);
|
|
465
|
+
const lines = ['经验已提交:' + rec.lessonId + '(' + rec.state + ')'];
|
|
466
|
+
lines.push(rec.created === true ? '· 新候选经验已保存。' : '· 已合并到既有经验,未新增记录。');
|
|
467
|
+
lines.push('· 独立支持数:' + rec.independentSupportCount);
|
|
468
|
+
const notes = Array.isArray(rec.notes) ? rec.notes : [];
|
|
469
|
+
for (const note of notes)
|
|
470
|
+
lines.push('· ' + note);
|
|
471
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
async execute(rawArgs) {
|
|
475
|
+
const args = asRecord(rawArgs);
|
|
476
|
+
const kind = parseLessonKind(args);
|
|
477
|
+
const title = maskIfNeeded(cfg, requiredString(args, 'title', '经验结论'));
|
|
478
|
+
const action = maskIfNeeded(cfg, requiredString(args, 'action', '可执行动作'));
|
|
479
|
+
const when = maskIfNeeded(cfg, requiredString(args, 'when', '适用条件'));
|
|
480
|
+
const exceptions = stringArray(args, 'exceptions').map((item) => maskIfNeeded(cfg, item));
|
|
481
|
+
const projectId = optionalString(args, 'projectId');
|
|
482
|
+
const workspaceRoot = optionalString(args, 'workspaceRoot');
|
|
483
|
+
const isGlobal = args.global === true;
|
|
484
|
+
const applicability = parseApplicability(args, 'applicability');
|
|
485
|
+
const maskedApplicability = applicability?.map((entry) => ({
|
|
486
|
+
...(entry.package !== undefined ? { package: maskIfNeeded(cfg, entry.package) } : {}),
|
|
487
|
+
...(entry.versions !== undefined ? { versions: maskIfNeeded(cfg, entry.versions) } : {}),
|
|
488
|
+
...(entry.platform !== undefined ? { platform: maskIfNeeded(cfg, entry.platform) } : {}),
|
|
489
|
+
}));
|
|
490
|
+
const evidenceInputs = parseEvidenceArgs(args, 'evidence', projectId)
|
|
491
|
+
.map((entry) => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }));
|
|
492
|
+
const scope = {
|
|
493
|
+
global: isGlobal,
|
|
494
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
495
|
+
...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
|
|
496
|
+
};
|
|
497
|
+
const lessonInput = {
|
|
498
|
+
kind,
|
|
499
|
+
title,
|
|
500
|
+
action,
|
|
501
|
+
when,
|
|
502
|
+
...(exceptions.length > 0 ? { exceptions } : {}),
|
|
503
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
504
|
+
...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
|
|
505
|
+
global: isGlobal,
|
|
506
|
+
...(maskedApplicability !== undefined ? { applicability: maskedApplicability } : {}),
|
|
507
|
+
evidence: evidenceInputs,
|
|
508
|
+
};
|
|
509
|
+
const idempotencyKey = 'dream_learn:' + sha256Short(JSON.stringify({
|
|
510
|
+
kind, title, action, when, exceptions,
|
|
511
|
+
projectId: projectId ?? null,
|
|
512
|
+
workspaceRoot: workspaceRoot ?? null,
|
|
513
|
+
global: isGlobal,
|
|
514
|
+
applicability: maskedApplicability ?? null,
|
|
515
|
+
evidence: evidenceInputs.map((entry) => ({
|
|
516
|
+
kind: entry.kind,
|
|
517
|
+
sessionId: entry.sessionId ?? null,
|
|
518
|
+
recordSeq: entry.recordSeq ?? null,
|
|
519
|
+
summary: entry.summary,
|
|
520
|
+
verification: entry.verification,
|
|
521
|
+
})),
|
|
522
|
+
}));
|
|
523
|
+
const store = knowledgeStoreOf(cfg);
|
|
524
|
+
let { lesson, created } = store.createLesson(lessonInput, idempotencyKey);
|
|
525
|
+
// R2:user-correction + read 证据视为用户已采纳;初始 candidate 经验直接进入 usable,
|
|
526
|
+
// 并在 review 记 actor=user。若存储层未来也实现 R2,这里会因 state 已 usable 而跳过(兼容)。
|
|
527
|
+
const hasUserCorrectionRead = evidenceInputs.some((entry) => entry.kind === 'user-correction' && entry.verification === 'read');
|
|
528
|
+
const reviewIsUserAccepted = lesson.review.decision === 'accepted' && lesson.review.actor === 'user';
|
|
529
|
+
const userReviewAllowed = lesson.state === 'candidate' || lesson.state === 'usable';
|
|
530
|
+
// 兼容两种时序:R2′ 未落盘时 createLesson 返回 candidate;已落盘时返回 usable(review 未必带 actor)。
|
|
531
|
+
// user-correction + read 一律记为用户已采纳;rejected/disputed/stale 不覆盖既有审阅结论。
|
|
532
|
+
if (hasUserCorrectionRead && !reviewIsUserAccepted && userReviewAllowed) {
|
|
533
|
+
lesson = store.reviewLesson(lesson.id, 'accepted', lesson.revision, 'dream_learn:user-correction:' + lesson.id + ':' + lesson.revision, 'user');
|
|
534
|
+
}
|
|
535
|
+
const byId = new Map(store.listEvidence().map((item) => [item.id, item]));
|
|
536
|
+
const evidence = lesson.evidenceIds
|
|
537
|
+
.map((id) => byId.get(id))
|
|
538
|
+
.filter((item) => item !== undefined)
|
|
539
|
+
.map((item) => ({ id: item.id, verification: item.verification }));
|
|
540
|
+
const notes = [];
|
|
541
|
+
if (evidenceInputs.length === 0) {
|
|
542
|
+
notes.push('无证据:仅保存为 candidate,不会作为已证实结论注入任务建议。');
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
const readCount = evidence.filter((item) => item.verification === 'read').length;
|
|
546
|
+
const claimedCount = evidence.filter((item) => item.verification === 'claimed').length;
|
|
547
|
+
notes.push('证据:' + readCount + ' 条已读(计入独立支持),' + claimedCount + ' 条仅声明(不计入独立支持)。');
|
|
548
|
+
}
|
|
549
|
+
if (hasUserCorrectionRead) {
|
|
550
|
+
if (lesson.state === 'usable' && lesson.review.decision === 'accepted' && lesson.review.actor === 'user') {
|
|
551
|
+
notes.push('检测到 user-correction + read 证据:按 R2 视为用户已采纳(state=usable,review.actor=user)。');
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
notes.push('检测到 user-correction + read 证据,但经验当前状态为 ' + lesson.state + ':不覆盖既有审阅结论。');
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (!created)
|
|
558
|
+
notes.push('已存在同指纹经验(' + lesson.id + '):复用既有记录并合并新证据,未新增经验。');
|
|
559
|
+
if (!isGlobal && projectId === undefined && workspaceRoot === undefined) {
|
|
560
|
+
notes.push('未提供 projectId/workspaceRoot 且未声明 global:该经验不会自动注入任何项目。');
|
|
561
|
+
}
|
|
562
|
+
if (cfg.maskSecrets)
|
|
563
|
+
notes.push('写入前已按 maskSecrets 脱敏。');
|
|
564
|
+
const fingerprint = lessonFingerprint({ kind, title, action, when, scope });
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
lessonId: lesson.id,
|
|
568
|
+
state: lesson.state,
|
|
569
|
+
created,
|
|
570
|
+
independentSupportCount: lesson.independentSupportCount,
|
|
571
|
+
evidence,
|
|
572
|
+
dedup: { ...(created ? {} : { merged: lesson.id }), fingerprint },
|
|
573
|
+
notes,
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
timeoutMs: 15000,
|
|
577
|
+
};
|
|
578
|
+
const dreamContext = {
|
|
579
|
+
name: 'dream_context',
|
|
580
|
+
description: '取梦:按当前任务返回少量真正适用的经验(只读,默认最多 5 条 / 3000 字符)。只返回 global 或与 projectId/workspaceRoot 精确匹配的经验;未知项目只返回 global,绝不跨项目扫描。rejected/stale/disputed 默认排除;候选默认不注入(candidate-hold),无独立证据且非 usable 不注入(no-evidence),版本条件与包名不符不注入(version-mismatch),skipped 均给原因。未审阅候选不得当作已证实结论;只有明确审阅候选时才传 includeCandidates=true。when/exceptions 完整保留,元数据可按预算截断,预算放不下即停。',
|
|
581
|
+
parameters: compileParameters({
|
|
582
|
+
query: { type: 'string', required: true, description: '当前任务描述或检索关键词。' },
|
|
583
|
+
projectId: { type: 'string', description: '当前项目 ID(可选;未知时只返回 global)。' },
|
|
584
|
+
workspaceRoot: { type: 'string', description: '当前工作区根目录(可选)。' },
|
|
585
|
+
limit: { type: 'integer', description: '条数上限 1-20(默认 5)。' },
|
|
586
|
+
maxChars: { type: 'integer', description: '字符预算 1-20000(默认 3000)。' },
|
|
587
|
+
packageVersion: { type: 'string', description: '当前相关依赖版本(可选;需配合包名被识别才参与版本判定)。' },
|
|
588
|
+
packageName: { type: 'string', description: '当前相关依赖包名(可选;与 query 文本一起识别 applicability.package)。' },
|
|
589
|
+
includeCandidates: { type: 'boolean', description: '是否把候选经验纳入任务建议(默认 false;仅明确审阅候选时传 true,无证据候选仍不注入)。' },
|
|
590
|
+
}),
|
|
591
|
+
output: {
|
|
592
|
+
schema: baseSchema,
|
|
593
|
+
render: (_args, value) => {
|
|
594
|
+
const rec = asRecord(value);
|
|
595
|
+
const items = Array.isArray(rec.items) ? rec.items : [];
|
|
596
|
+
const budget = asRecord(rec.budget);
|
|
597
|
+
if (items.length === 0) {
|
|
598
|
+
const skipped = Array.isArray(rec.skipped) ? rec.skipped : [];
|
|
599
|
+
const countReason = (reason) => skipped.filter((entry) => asRecord(entry).reason === reason).length;
|
|
600
|
+
const hints = [];
|
|
601
|
+
const held = countReason('candidate-hold');
|
|
602
|
+
const noEvidence = countReason('no-evidence');
|
|
603
|
+
const mismatched = countReason('version-mismatch');
|
|
604
|
+
if (held > 0)
|
|
605
|
+
hints.push(held + ' 条候选待审阅(要审阅候选时显式传 includeCandidates=true)');
|
|
606
|
+
if (noEvidence > 0)
|
|
607
|
+
hints.push(noEvidence + ' 条无独立证据不注入');
|
|
608
|
+
if (mismatched > 0)
|
|
609
|
+
hints.push(mismatched + ' 条版本条件不匹配不注入');
|
|
610
|
+
const suffix = hints.length > 0 ? ';' + hints.join(';') : '';
|
|
611
|
+
return [{ type: 'text', text: '没有可注入的适用经验(范围:' + rec.scopeLabel + suffix + ')。' }];
|
|
612
|
+
}
|
|
613
|
+
const lines = ['适用经验 ' + items.length + ' 条(范围:' + rec.scopeLabel + ',字符 ' + budget.usedChars + '/' + budget.maxChars + '):'];
|
|
614
|
+
for (const item of items) {
|
|
615
|
+
const lesson = asRecord(item);
|
|
616
|
+
const truncation = lesson.truncated === true ? '|元数据截断' : '';
|
|
617
|
+
lines.push('- [' + lesson.state + '|' + lesson.scopeLabel + '|rev ' + lesson.revision + truncation + '] ' + lesson.title);
|
|
618
|
+
lines.push(' 何时:' + lesson.when);
|
|
619
|
+
lines.push(' 行动:' + lesson.action);
|
|
620
|
+
const exceptions = Array.isArray(lesson.exceptions) ? lesson.exceptions : [];
|
|
621
|
+
if (exceptions.length > 0)
|
|
622
|
+
lines.push(' 例外:' + exceptions.join(';'));
|
|
623
|
+
lines.push(' 为何相关:' + lesson.whyRelevant);
|
|
624
|
+
lines.push(' 证据:' + lesson.evidenceSummary);
|
|
625
|
+
}
|
|
626
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
async execute(rawArgs) {
|
|
630
|
+
const args = asRecord(rawArgs);
|
|
631
|
+
const query = requiredString(args, 'query', '检索关键词');
|
|
632
|
+
const projectId = optionalString(args, 'projectId');
|
|
633
|
+
const workspaceRoot = optionalString(args, 'workspaceRoot');
|
|
634
|
+
const packageVersion = optionalString(args, 'packageVersion');
|
|
635
|
+
const packageName = optionalString(args, 'packageName');
|
|
636
|
+
const { limit, maxChars } = resolveRetrievalBudget({
|
|
637
|
+
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
638
|
+
maxChars: typeof args.maxChars === 'number' ? args.maxChars : undefined,
|
|
639
|
+
});
|
|
640
|
+
const retrievalQuery = {
|
|
641
|
+
query,
|
|
642
|
+
limit,
|
|
643
|
+
maxChars,
|
|
644
|
+
// R1:默认不注入候选;claimed-only / 无证据且非 usable 的条目也不注入(R1 附带规则)。
|
|
645
|
+
includeCandidates: args.includeCandidates === true,
|
|
646
|
+
includeNoEvidence: false,
|
|
647
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
648
|
+
...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
|
|
649
|
+
...(packageVersion !== undefined ? { packageVersion } : {}),
|
|
650
|
+
...(packageName !== undefined ? { packageName } : {}),
|
|
651
|
+
};
|
|
652
|
+
const scopeLabel = queryScopeLabel(retrievalQuery);
|
|
653
|
+
const dir = knowledgeDirOf(cfg);
|
|
654
|
+
if (!existsSync(dir)) {
|
|
655
|
+
return { items: [], skipped: [], scopeLabel, budget: { limit, maxChars, usedChars: 0 } };
|
|
656
|
+
}
|
|
657
|
+
const store = knowledgeStoreOf(cfg);
|
|
658
|
+
const result = retrieveLessons(store.listLessons(), store.listEvidence(), retrievalQuery);
|
|
659
|
+
const usedChars = result.items.reduce((total, item) => total + retrievedLessonChars(item), 0);
|
|
660
|
+
return { items: result.items, skipped: result.skipped, scopeLabel, budget: { limit, maxChars, usedChars } };
|
|
661
|
+
},
|
|
662
|
+
timeoutMs: 15000,
|
|
663
|
+
};
|
|
664
|
+
const REVIEW_ACTIONS = ['accept', 'reject', 'mark-stale', 'mark-disputed', 'resolve-conflict', 'attach-evidence'];
|
|
665
|
+
const dreamReview = {
|
|
666
|
+
name: 'dream_review',
|
|
667
|
+
description: '审梦:采纳/驳回/标记冲突或过期/追加验证证据,必须带 expectedRevision(revision 不符会拒绝且不写入)。模型调用 actor 一律记为 model,不得冒充用户采纳;attach-evidence 只追加证据,不自动改变 state。',
|
|
668
|
+
parameters: compileParameters({
|
|
669
|
+
action: { type: 'string', required: true, enum: [...REVIEW_ACTIONS], description: '审阅动作。' },
|
|
670
|
+
lessonId: { type: 'string', required: true, description: '经验 ID。' },
|
|
671
|
+
expectedRevision: { type: 'integer', required: true, description: '期望 revision(乐观锁,必须与当前一致)。' },
|
|
672
|
+
evidence: { type: 'array', items: evidenceItemsSpec(), description: 'attach-evidence 时使用的证据数组。' },
|
|
673
|
+
conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed 记录冲突对象;resolve-conflict 时解除这些冲突 ID。' },
|
|
674
|
+
note: { type: 'string', description: '备注(仅用于幂等键,不落盘为正文)。' },
|
|
675
|
+
}),
|
|
676
|
+
output: {
|
|
677
|
+
schema: baseSchema,
|
|
678
|
+
render: (_args, value) => {
|
|
679
|
+
const rec = asRecord(value);
|
|
680
|
+
return [{
|
|
681
|
+
type: 'text',
|
|
682
|
+
text: '经验 ' + asRecord(rec.lesson).id + ':' + rec.previousState + ' → ' + rec.state + '(revision ' + rec.revision + ')。',
|
|
683
|
+
}];
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
async execute(rawArgs) {
|
|
687
|
+
const args = asRecord(rawArgs);
|
|
688
|
+
const action = optionalString(args, 'action');
|
|
689
|
+
if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
|
|
690
|
+
throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
|
|
691
|
+
}
|
|
692
|
+
const lessonId = requiredString(args, 'lessonId', '经验 ID');
|
|
693
|
+
const revisionRaw = args.expectedRevision;
|
|
694
|
+
if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
|
|
695
|
+
throw new Error('expectedRevision 必须是 >= 1 的整数。');
|
|
696
|
+
}
|
|
697
|
+
const expectedRevision = revisionRaw;
|
|
698
|
+
const store = knowledgeStoreOf(cfg);
|
|
699
|
+
const before = store.getLesson(lessonId);
|
|
700
|
+
if (before === undefined)
|
|
701
|
+
throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
|
|
702
|
+
if (before.revision !== expectedRevision) {
|
|
703
|
+
const error = new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
|
|
704
|
+
error.currentRevision = before.revision;
|
|
705
|
+
error.details = { currentRevision: before.revision };
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
const conflictWith = stringArray(args, 'conflictWith');
|
|
709
|
+
const note = optionalString(args, 'note');
|
|
710
|
+
const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
|
|
711
|
+
action, lessonId, expectedRevision, conflictWith, note: note ?? null,
|
|
712
|
+
}));
|
|
713
|
+
// 模型调用永远记为 model;入参里的 actor 一律忽略(用户采纳只能来自宿主授权的真实用户指令)。
|
|
714
|
+
const actor = 'model';
|
|
715
|
+
let lesson;
|
|
716
|
+
switch (action) {
|
|
717
|
+
case 'accept':
|
|
718
|
+
lesson = store.reviewLesson(lessonId, 'accepted', expectedRevision, idempotencyKey, actor);
|
|
719
|
+
break;
|
|
720
|
+
case 'reject':
|
|
721
|
+
lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
|
|
722
|
+
break;
|
|
723
|
+
case 'mark-stale':
|
|
724
|
+
lesson = store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
|
|
725
|
+
break;
|
|
726
|
+
case 'mark-disputed': {
|
|
727
|
+
if (conflictWith.length === 0) {
|
|
728
|
+
lesson = store.applyTransition(lessonId, 'disputed', expectedRevision, idempotencyKey);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const conflictIds = [...before.conflictIds];
|
|
732
|
+
for (const id of conflictWith) {
|
|
733
|
+
if (!conflictIds.includes(id))
|
|
734
|
+
conflictIds.push(id);
|
|
735
|
+
}
|
|
736
|
+
lesson = store.updateLesson(lessonId, { state: 'disputed', conflictIds }, expectedRevision, idempotencyKey);
|
|
737
|
+
}
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
case 'resolve-conflict': {
|
|
741
|
+
if (conflictWith.length === 0) {
|
|
742
|
+
lesson = store.applyTransition(lessonId, 'usable', expectedRevision, idempotencyKey);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
const conflictIds = before.conflictIds.filter((id) => !conflictWith.includes(id));
|
|
746
|
+
lesson = store.updateLesson(lessonId, { state: 'usable', conflictIds }, expectedRevision, idempotencyKey);
|
|
747
|
+
}
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case 'attach-evidence': {
|
|
751
|
+
const inputs = parseEvidenceArgs(args, 'evidence', before.scope.projectId);
|
|
752
|
+
if (inputs.length === 0)
|
|
753
|
+
throw new Error('attach-evidence 需要至少一条 evidence。');
|
|
754
|
+
const masked = inputs.map((entry) => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }));
|
|
755
|
+
const appended = masked.map((entry) => store.appendEvidence(entry));
|
|
756
|
+
const evidenceIds = [...before.evidenceIds];
|
|
757
|
+
for (const item of appended) {
|
|
758
|
+
if (!evidenceIds.includes(item.evidence.id))
|
|
759
|
+
evidenceIds.push(item.evidence.id);
|
|
760
|
+
}
|
|
761
|
+
if (evidenceIds.length === before.evidenceIds.length) {
|
|
762
|
+
lesson = before;
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
lesson = store.updateLesson(lessonId, { evidenceIds }, expectedRevision, idempotencyKey);
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
default:
|
|
770
|
+
throw new Error('不支持的动作:' + action);
|
|
771
|
+
}
|
|
772
|
+
return { ok: true, lesson, previousState: before.state, state: lesson.state, revision: lesson.revision };
|
|
773
|
+
},
|
|
774
|
+
timeoutMs: 15000,
|
|
775
|
+
};
|
|
776
|
+
return [dreamDigest, dreamSave, dreamJournal, dreamRecall, dreamBridge, dreamHealth, dreamLearn, dreamContext, dreamReview];
|
|
268
777
|
}
|
|
269
778
|
/** 把会话摘要拼成一段可读文本(供模型一次性阅读)。 */
|
|
270
779
|
export function buildDigestText(digest) {
|
package/lib/web.d.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-dream 的网页端后端:给设置页里的梦境面板提供只读数据。
|
|
3
|
-
*
|
|
4
|
-
* 安全模型沿用 dsh-calendar / dsh-email 的设置路由:这个路由能读出用户的
|
|
5
|
-
* 全部梦境日记(都是隐私文本),而宿主 webserver 有可能绑定在 0.0.0.0。
|
|
6
|
-
* 三道门缺一不可 ——
|
|
7
|
-
* 1. remoteAddress 若是明确的非回环地址则拒绝(局域网不可达);
|
|
8
|
-
* 2. Host 头必须是 localhost 名(挡 DNS rebinding:恶意域名解析到 127.0.0.1);
|
|
9
|
-
* 3. 只接受 GET:本路由没有任何写路径,所以不需要 CSRF 那一套。
|
|
10
|
-
*
|
|
11
|
-
* 这一层不做自己的状态:每次请求都从 journal.ts 现读现算,磁盘上的 JSONL
|
|
12
|
-
* 就是唯一真相。
|
|
13
|
-
*
|
|
14
|
-
* @module dsh-dream/web
|
|
15
|
-
*/
|
|
16
1
|
import { type ResolvedDreamConfig } from './config.js';
|
|
17
2
|
import { type DreamEntry, type DreamStats } from './journal.js';
|
|
3
|
+
import type { Evidence, Lesson } from './knowledge.js';
|
|
18
4
|
/** 面板与浏览器说话的同源路由。 */
|
|
19
5
|
export declare const DREAM_ROUTE = "/_dsh/dsh-dream/journal";
|
|
6
|
+
/** M1 经验面板的只读路由。 */
|
|
7
|
+
export declare const DREAM_KNOWLEDGE_ROUTE = "/_dsh/dsh-dream/knowledge";
|
|
20
8
|
/** ?limit 的默认值与上下界。 */
|
|
21
9
|
export declare const DREAM_LIMIT_DEFAULT = 50;
|
|
22
10
|
export declare const DREAM_LIMIT_MIN = 1;
|
|
@@ -36,19 +24,59 @@ export interface DreamWebPayload {
|
|
|
36
24
|
/** 本次生效的条数上限。 */
|
|
37
25
|
limit: number;
|
|
38
26
|
}
|
|
27
|
+
/** 知识路由的统计形状(与 FREEZE §2 的 stats() 对齐;v1.1 R8 透传 badLines)。 */
|
|
28
|
+
export interface KnowledgeWebStats {
|
|
29
|
+
lessons: number;
|
|
30
|
+
evidence: number;
|
|
31
|
+
events: number;
|
|
32
|
+
byState: Record<string, number>;
|
|
33
|
+
/** 有界回放被截断时为 true(面板据此显示提示行)。 */
|
|
34
|
+
truncated?: boolean;
|
|
35
|
+
/** 事件 / 证据流中被跳过的坏行数(面板据此显示提示行)。 */
|
|
36
|
+
badLines: number;
|
|
37
|
+
}
|
|
38
|
+
/** 知识路由的 JSON 形状:脱敏后的经验 / 证据 / 统计。 */
|
|
39
|
+
export interface KnowledgeWebPayload {
|
|
40
|
+
/** 每条经验额外带 scopeLabel 与 evidenceSummary(与 dream_context 同源文案)。 */
|
|
41
|
+
lessons: Array<Lesson & {
|
|
42
|
+
scopeLabel: string;
|
|
43
|
+
evidenceSummary: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** 脱敏后的证据记录。 */
|
|
46
|
+
evidence: Evidence[];
|
|
47
|
+
/** 全量统计(不受条数影响)。 */
|
|
48
|
+
stats: KnowledgeWebStats;
|
|
49
|
+
}
|
|
50
|
+
/** 只读知识源的最小接口:方便测试注入假数据,生产默认用 KnowledgeStore。 */
|
|
51
|
+
export interface KnowledgeReader {
|
|
52
|
+
listLessons(): Lesson[];
|
|
53
|
+
listEvidence(): Evidence[];
|
|
54
|
+
stats(): KnowledgeWebStats;
|
|
55
|
+
}
|
|
39
56
|
/** 安装参数;journalDir 缺省时从 config 解析。 */
|
|
40
57
|
export interface DreamWebOptions {
|
|
41
58
|
/** 梦境日记目录(优先)。 */
|
|
42
59
|
journalDir?: string;
|
|
60
|
+
/** 知识目录(优先;缺省 = <journalDir>/knowledge)。 */
|
|
61
|
+
knowledgeDir?: string;
|
|
43
62
|
/** 插件配置,用来兜底 journalDir(resolveConfig 的入参)。 */
|
|
44
63
|
config?: Record<string, unknown> | ResolvedDreamConfig | null;
|
|
64
|
+
/** 测试注入:返回只读知识源;提供时不做目录存在性检查。 */
|
|
65
|
+
knowledgeStoreFactory?: (knowledgeDir: string) => KnowledgeReader;
|
|
45
66
|
}
|
|
46
67
|
/** ?limit 解析:缺省/非数字 → fallback;越界钳制到 [1, 500]。 */
|
|
47
68
|
export declare function limitFromQuery(raw: unknown, fallback?: number): number;
|
|
48
|
-
/**
|
|
69
|
+
/** 解析后的 knowledgeDir:显式路径优先,否则 = <journalDir>/knowledge。 */
|
|
70
|
+
export declare function knowledgeDirOf(options: DreamWebOptions, journalDir?: string): string;
|
|
71
|
+
/** 知识目录不存在(或尚未建立)时的空载荷:不写盘、不报 404。 */
|
|
72
|
+
export declare function emptyKnowledgePayload(): KnowledgeWebPayload;
|
|
73
|
+
/** 组装梦境日记路由处理器;journalDir 在这一刻定下来(与插件的配置生命周期一致)。 */
|
|
49
74
|
export declare function createDreamWebHandler(options?: DreamWebOptions): (req: any, res: any) => Promise<void>;
|
|
75
|
+
/** 组装只读经验路由处理器;零写盘:目录不存在就直接返回空结果。 */
|
|
76
|
+
export declare function createKnowledgeWebHandler(options?: DreamWebOptions): (req: any, res: any) => Promise<void>;
|
|
50
77
|
/**
|
|
51
|
-
*
|
|
52
|
-
* 与 dsh-calendar 相同:ctx.inject(['webServer']) + effect
|
|
78
|
+
* 把只读路由挂到宿主 webserver 上。
|
|
79
|
+
* 与 dsh-calendar 相同:ctx.inject(['webServer']) + effect 注册,插件卸载即摘掉路由;
|
|
80
|
+
* 同一个 effect 里注册日记与知识两条 exact 路由,卸载时一起释放。
|
|
53
81
|
*/
|
|
54
82
|
export declare function installDreamWeb(ctx: any, options?: DreamWebOptions): void;
|