@stardustlc/dsh-dream 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.en.md +39 -14
- package/README.md +43 -18
- package/lib/bridge.d.ts +205 -2
- package/lib/bridge.js +1053 -6
- package/lib/client.js +634 -27
- package/lib/evidence.d.ts +15 -0
- package/lib/evidence.js +123 -0
- package/lib/index.js +3 -1
- package/lib/knowledge-store.d.ts +23 -5
- package/lib/knowledge-store.js +311 -41
- package/lib/knowledge.d.ts +40 -0
- package/lib/knowledge.js +54 -0
- package/lib/mask.d.ts +3 -1
- package/lib/mask.js +8 -2
- package/lib/retrieval.d.ts +12 -0
- package/lib/retrieval.js +68 -3
- package/lib/tools.d.ts +2 -0
- package/lib/tools.js +436 -128
- package/lib/web-actions.d.ts +11 -0
- package/lib/web-actions.js +175 -0
- package/lib/web.d.ts +50 -1
- package/lib/web.js +153 -13
- package/package.json +2 -2
- package/skills/dream-protocol/SKILL.md +9 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ResolvedDreamConfig } from './config.js';
|
|
2
|
+
export declare const DREAM_ACTION_ROUTE = "/api/dsh-dream/actions";
|
|
3
|
+
type Options = {
|
|
4
|
+
journalDir?: string;
|
|
5
|
+
config?: Record<string, unknown> | ResolvedDreamConfig | null;
|
|
6
|
+
};
|
|
7
|
+
/** Connection has authenticated the operator before this handler is called. */
|
|
8
|
+
export declare function createDreamActionHandler(options?: Options): (request: Request) => Promise<Response>;
|
|
9
|
+
/** No unauthenticated webServer fallback: old hosts keep their read-only panel. */
|
|
10
|
+
export declare function installDreamActions(ctx: any, options?: Options): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/** Mutations are mounted exclusively on Connection's authenticated Fetch carrier. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { resolveConfig } from './config.js';
|
|
6
|
+
import { KnowledgeError } from './knowledge.js';
|
|
7
|
+
import { KnowledgeStore } from './knowledge-store.js';
|
|
8
|
+
import { maskSecrets } from './mask.js';
|
|
9
|
+
import { buildDreamTools, executeDreamReview } from './tools.js';
|
|
10
|
+
import { rollbackBridge } from './bridge.js';
|
|
11
|
+
import { maskDeep } from './web.js';
|
|
12
|
+
export const DREAM_ACTION_ROUTE = '/api/dsh-dream/actions';
|
|
13
|
+
const BODY_LIMIT = 64 * 1024;
|
|
14
|
+
const PREVIEW_LIFETIME = 10 * 60 * 1000;
|
|
15
|
+
const MAX_PREVIEWS = 100;
|
|
16
|
+
function json(status, value) {
|
|
17
|
+
return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
18
|
+
}
|
|
19
|
+
function fail(status, code, message) {
|
|
20
|
+
return json(status, { ok: false, error: { code, message: maskSecrets(message) } });
|
|
21
|
+
}
|
|
22
|
+
function text(body, key) {
|
|
23
|
+
const value = body[key];
|
|
24
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > 2000)
|
|
25
|
+
throw new KnowledgeError('invalid', key + ' 必须是非空文本,最多 2000 字符');
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
function checkRevision(store, body) {
|
|
29
|
+
const id = text(body, 'lessonId');
|
|
30
|
+
const lesson = store.getLesson(id);
|
|
31
|
+
if (lesson === undefined)
|
|
32
|
+
throw new KnowledgeError('invalid', '经验不存在,请刷新列表');
|
|
33
|
+
if (typeof body.expectedRevision !== 'number' || !Number.isInteger(body.expectedRevision) || body.expectedRevision < 1)
|
|
34
|
+
throw new KnowledgeError('invalid', '缺少有效的经验版本');
|
|
35
|
+
if (lesson.revision !== body.expectedRevision)
|
|
36
|
+
throw new KnowledgeError('revision', '经验已更新,请刷新后重新操作');
|
|
37
|
+
return lesson;
|
|
38
|
+
}
|
|
39
|
+
/** Connection has authenticated the operator before this handler is called. */
|
|
40
|
+
export function createDreamActionHandler(options = {}) {
|
|
41
|
+
const cfg = { ...resolveConfig(options.config), ...(options.journalDir === undefined ? {} : { journalDir: options.journalDir }) };
|
|
42
|
+
const store = new KnowledgeStore(join(cfg.journalDir, 'knowledge'), { maskSecrets: cfg.maskSecrets });
|
|
43
|
+
const bridge = buildDreamTools(cfg).find(tool => tool.name === 'dream_bridge');
|
|
44
|
+
const previews = new Map();
|
|
45
|
+
return async (request) => {
|
|
46
|
+
if (request.method !== 'POST')
|
|
47
|
+
return fail(405, 'method', '请使用 POST');
|
|
48
|
+
if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
|
|
49
|
+
return fail(415, 'content-type', '需要 application/json');
|
|
50
|
+
// A web form or cross-origin simple request cannot supply this header.
|
|
51
|
+
if (request.headers.get('x-dsh-dream-action') !== '1')
|
|
52
|
+
return fail(403, 'forbidden', '请从 Dream 面板操作');
|
|
53
|
+
const site = request.headers.get('sec-fetch-site');
|
|
54
|
+
if (site !== null && site !== 'same-origin' && site !== 'none')
|
|
55
|
+
return fail(403, 'forbidden', '拒绝跨站操作');
|
|
56
|
+
const origin = request.headers.get('origin');
|
|
57
|
+
if (origin !== null && /^https?:/i.test(origin)) {
|
|
58
|
+
try {
|
|
59
|
+
// The official node:http bridge intentionally uses http://dsh.internal
|
|
60
|
+
// as its Fetch URL base; the preserved Host is the original authority.
|
|
61
|
+
const host = request.headers.get('host');
|
|
62
|
+
const authority = host === null ? new URL(request.url).host : new URL('http://' + host).host;
|
|
63
|
+
if (new URL(origin).host !== authority)
|
|
64
|
+
return fail(403, 'forbidden', '拒绝跨源操作');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return fail(403, 'forbidden', '无效来源');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const reader = request.body?.getReader();
|
|
72
|
+
if (reader === undefined)
|
|
73
|
+
return fail(400, 'invalid', '缺少请求内容');
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
try {
|
|
77
|
+
for (;;) {
|
|
78
|
+
const part = await reader.read();
|
|
79
|
+
if (part.done)
|
|
80
|
+
break;
|
|
81
|
+
size += part.value.byteLength;
|
|
82
|
+
if (size > BODY_LIMIT) {
|
|
83
|
+
await reader.cancel();
|
|
84
|
+
return fail(413, 'too-large', '请求内容超过 64 KB');
|
|
85
|
+
}
|
|
86
|
+
chunks.push(part.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
reader.releaseLock();
|
|
91
|
+
}
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return fail(400, 'invalid', '请求不是有效 JSON');
|
|
98
|
+
}
|
|
99
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
100
|
+
return fail(400, 'invalid', '请求必须是对象');
|
|
101
|
+
const body = raw;
|
|
102
|
+
if (request.signal.aborted)
|
|
103
|
+
return fail(409, 'cancelled', '操作已取消');
|
|
104
|
+
const operation = text(body, 'operation');
|
|
105
|
+
if (operation === 'review') {
|
|
106
|
+
if (!['accept', 'reject', 'reopen', 'mark-stale', 'resolve-conflict'].includes(String(body.action)))
|
|
107
|
+
throw new KnowledgeError('invalid', '不支持此页面审阅动作');
|
|
108
|
+
checkRevision(store, body);
|
|
109
|
+
const result = await executeDreamReview(cfg, body, undefined, 'human');
|
|
110
|
+
return json(200, maskDeep(result));
|
|
111
|
+
}
|
|
112
|
+
if (operation === 'feedback') {
|
|
113
|
+
// The store checks revision inside its lock and keeps retry keys idempotent.
|
|
114
|
+
const id = text(body, 'lessonId');
|
|
115
|
+
const key = text(body, 'requestId');
|
|
116
|
+
if (!/^[a-zA-Z0-9_-]{8,100}$/.test(key))
|
|
117
|
+
throw new KnowledgeError('invalid', '反馈请求标识无效');
|
|
118
|
+
const note = body.note === undefined ? undefined : text(body, 'note');
|
|
119
|
+
const lesson = store.recordFeedback(id, body.vote, note, body.expectedRevision, 'web-feedback:' + key);
|
|
120
|
+
return json(200, maskDeep({ ok: true, lesson, message: '已记录使用反馈,审阅状态保持原样' }));
|
|
121
|
+
}
|
|
122
|
+
for (const [key, value] of previews)
|
|
123
|
+
if (value.expires < Date.now())
|
|
124
|
+
previews.delete(key);
|
|
125
|
+
if (operation === 'preview') {
|
|
126
|
+
const lesson = checkRevision(store, body);
|
|
127
|
+
const root = lesson.scope.workspaceRoot;
|
|
128
|
+
if (root === undefined || !isAbsolute(root))
|
|
129
|
+
throw new KnowledgeError('invalid', '这条经验没有项目目录,请在对应项目的对话中预览规则');
|
|
130
|
+
const canonicalRoot = realpathSync(root);
|
|
131
|
+
const result = await bridge.execute({ mode: 'preview', lessonIds: [lesson.id], path: 'AGENTS.md', projectId: lesson.scope.projectId }, { cwd: canonicalRoot });
|
|
132
|
+
if (result.ok !== true)
|
|
133
|
+
return json(409, maskDeep(result));
|
|
134
|
+
if (result.lessons.length === 0)
|
|
135
|
+
return fail(409, 'ineligible', '当前经验不满足写入资格,请先查看适用范围与审阅状态');
|
|
136
|
+
while (previews.size >= MAX_PREVIEWS)
|
|
137
|
+
previews.delete(previews.keys().next().value);
|
|
138
|
+
const token = randomUUID();
|
|
139
|
+
previews.set(token, { expires: Date.now() + PREVIEW_LIFETIME, root: canonicalRoot, lessonId: lesson.id, revision: lesson.revision, projectId: lesson.scope.projectId, sha: result.expected.sha256, previewId: result.previewId });
|
|
140
|
+
return json(200, { ...maskDeep(result), token });
|
|
141
|
+
}
|
|
142
|
+
if (operation === 'apply') {
|
|
143
|
+
const token = text(body, 'token');
|
|
144
|
+
const preview = previews.get(token);
|
|
145
|
+
if (preview === undefined)
|
|
146
|
+
return fail(409, 'preview-expired', '预览已过期,请重新预览');
|
|
147
|
+
const lesson = checkRevision(store, { lessonId: preview.lessonId, expectedRevision: preview.revision });
|
|
148
|
+
if (realpathSync(lesson.scope.workspaceRoot) !== preview.root)
|
|
149
|
+
return fail(409, 'revision', '项目目录已变化,请重新预览');
|
|
150
|
+
const result = await bridge.execute({ mode: 'apply', lessonIds: [preview.lessonId], path: 'AGENTS.md', projectId: preview.projectId, expectedSha256: preview.sha, previewId: preview.previewId }, { cwd: preview.root });
|
|
151
|
+
previews.delete(token);
|
|
152
|
+
return json(result.ok === true ? 200 : 409, maskDeep(result));
|
|
153
|
+
}
|
|
154
|
+
if (operation === 'rollback') {
|
|
155
|
+
const result = rollbackBridge({ journalDir: cfg.journalDir, projectRoot: '', backupId: text(body, 'backupId') });
|
|
156
|
+
return json(result.ok === true ? 200 : 409, maskDeep(result));
|
|
157
|
+
}
|
|
158
|
+
throw new KnowledgeError('invalid', '不支持此操作');
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error instanceof KnowledgeError)
|
|
162
|
+
return fail(error.code === 'revision' || error.code === 'duplicate' ? 409 : error.code === 'io' ? 500 : 400, error.code, error.message);
|
|
163
|
+
return fail(500, 'io', '操作失败:' + (error instanceof Error ? error.message : String(error)));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** No unauthenticated webServer fallback: old hosts keep their read-only panel. */
|
|
168
|
+
export function installDreamActions(ctx, options = {}) {
|
|
169
|
+
const fetch = createDreamActionHandler(options);
|
|
170
|
+
ctx.inject(['connection'], (host) => {
|
|
171
|
+
if (typeof host.connection?.fetch?.register !== 'function')
|
|
172
|
+
return;
|
|
173
|
+
host.connection.fetch.register({ path: DREAM_ACTION_ROUTE, methods: ['POST'], requestBody: 'buffered', fetch });
|
|
174
|
+
});
|
|
175
|
+
}
|
package/lib/web.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { Evidence, Lesson } from './knowledge.js';
|
|
|
5
5
|
export declare const DREAM_ROUTE = "/_dsh/dsh-dream/journal";
|
|
6
6
|
/** M1 经验面板的只读路由。 */
|
|
7
7
|
export declare const DREAM_KNOWLEDGE_ROUTE = "/_dsh/dsh-dream/knowledge";
|
|
8
|
+
/** M2 写入记录面板的只读路由。 */
|
|
9
|
+
export declare const DREAM_BRIDGE_ROUTE = "/_dsh/dsh-dream/bridge";
|
|
8
10
|
/** ?limit 的默认值与上下界。 */
|
|
9
11
|
export declare const DREAM_LIMIT_DEFAULT = 50;
|
|
10
12
|
export declare const DREAM_LIMIT_MIN = 1;
|
|
@@ -53,30 +55,77 @@ export interface KnowledgeReader {
|
|
|
53
55
|
listEvidence(): Evidence[];
|
|
54
56
|
stats(): KnowledgeWebStats;
|
|
55
57
|
}
|
|
58
|
+
/** 写入记录的单条视图(展示用;字段缺失时按空字符串 / false 归一化)。 */
|
|
59
|
+
export interface BridgeWebLessonRef {
|
|
60
|
+
lessonId: string;
|
|
61
|
+
revision: number;
|
|
62
|
+
}
|
|
63
|
+
export interface BridgeWebRecord {
|
|
64
|
+
backupId: string;
|
|
65
|
+
at: string;
|
|
66
|
+
target: string;
|
|
67
|
+
action: string;
|
|
68
|
+
lessons: BridgeWebLessonRef[];
|
|
69
|
+
beforeSha256: string;
|
|
70
|
+
afterSha256: string;
|
|
71
|
+
rollbackable: boolean;
|
|
72
|
+
rollbackReason?: string;
|
|
73
|
+
}
|
|
74
|
+
/** 写入记录路由的 JSON 形状:脱敏后的应用记录 + 汇总。 */
|
|
75
|
+
export interface BridgeWebPayload {
|
|
76
|
+
records: BridgeWebRecord[];
|
|
77
|
+
stats: {
|
|
78
|
+
total: number;
|
|
79
|
+
rollbackable: number;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** 桥接存储层返回的原始记录形状(字段由 src/bridge.ts 负责)。 */
|
|
83
|
+
export type BridgeApplicationView = Record<string, unknown>;
|
|
84
|
+
/** 只读桥接记录源的最小接口:方便测试注入假数据。 */
|
|
85
|
+
export interface BridgeReader {
|
|
86
|
+
listApplications(): BridgeApplicationView[];
|
|
87
|
+
}
|
|
56
88
|
/** 安装参数;journalDir 缺省时从 config 解析。 */
|
|
57
89
|
export interface DreamWebOptions {
|
|
58
90
|
/** 梦境日记目录(优先)。 */
|
|
59
91
|
journalDir?: string;
|
|
60
92
|
/** 知识目录(优先;缺省 = <journalDir>/knowledge)。 */
|
|
61
93
|
knowledgeDir?: string;
|
|
94
|
+
/** 桥接记录目录(优先;缺省 = <journalDir>/bridge)。 */
|
|
95
|
+
bridgeDir?: string;
|
|
62
96
|
/** 插件配置,用来兜底 journalDir(resolveConfig 的入参)。 */
|
|
63
97
|
config?: Record<string, unknown> | ResolvedDreamConfig | null;
|
|
64
98
|
/** 测试注入:返回只读知识源;提供时不做目录存在性检查。 */
|
|
65
99
|
knowledgeStoreFactory?: (knowledgeDir: string) => KnowledgeReader;
|
|
100
|
+
/** 测试注入:返回只读桥接记录源;提供时不做目录存在性检查。 */
|
|
101
|
+
bridgeStoreFactory?: (bridgeDir: string) => BridgeReader;
|
|
66
102
|
}
|
|
67
103
|
/** ?limit 解析:缺省/非数字 → fallback;越界钳制到 [1, 500]。 */
|
|
68
104
|
export declare function limitFromQuery(raw: unknown, fallback?: number): number;
|
|
69
105
|
/** 解析后的 knowledgeDir:显式路径优先,否则 = <journalDir>/knowledge。 */
|
|
70
106
|
export declare function knowledgeDirOf(options: DreamWebOptions, journalDir?: string): string;
|
|
107
|
+
/** 解析后的 bridgeDir:显式路径优先,否则 = <journalDir>/bridge。 */
|
|
108
|
+
export declare function bridgeDirOf(options: DreamWebOptions, journalDir?: string): string;
|
|
109
|
+
/** 递归脱敏;结构化绝对路径按目录分段,避免 Unix 分隔符把正常路径连成长令牌。 */
|
|
110
|
+
export declare function maskDeep<T>(value: T, key?: string): T;
|
|
71
111
|
/** 知识目录不存在(或尚未建立)时的空载荷:不写盘、不报 404。 */
|
|
72
112
|
export declare function emptyKnowledgePayload(): KnowledgeWebPayload;
|
|
113
|
+
/**
|
|
114
|
+
* 归一化桥接应用记录:只保留展示需要的字段,坏形状跳过。
|
|
115
|
+
* 数据源由 src/bridge.ts 负责(本层不做 IO、不重算可否回滚)。
|
|
116
|
+
*/
|
|
117
|
+
export declare function normalizeBridgeApplications(value: unknown): BridgeWebRecord[];
|
|
118
|
+
/** 写入记录目录不存在时的空载荷。 */
|
|
119
|
+
export declare function emptyBridgePayload(): BridgeWebPayload;
|
|
73
120
|
/** 组装梦境日记路由处理器;journalDir 在这一刻定下来(与插件的配置生命周期一致)。 */
|
|
74
121
|
export declare function createDreamWebHandler(options?: DreamWebOptions): (req: any, res: any) => Promise<void>;
|
|
122
|
+
/** 组装只读写入记录路由处理器;零写盘:目录不存在就直接返回空结果。 */
|
|
123
|
+
export declare function createBridgeWebHandler(options?: DreamWebOptions): (req: any, res: any) => Promise<void>;
|
|
75
124
|
/** 组装只读经验路由处理器;零写盘:目录不存在就直接返回空结果。 */
|
|
76
125
|
export declare function createKnowledgeWebHandler(options?: DreamWebOptions): (req: any, res: any) => Promise<void>;
|
|
77
126
|
/**
|
|
78
127
|
* 把只读路由挂到宿主 webserver 上。
|
|
79
128
|
* 与 dsh-calendar 相同:ctx.inject(['webServer']) + effect 注册,插件卸载即摘掉路由;
|
|
80
|
-
* 同一个 effect
|
|
129
|
+
* 同一个 effect 里注册日记 / 知识 / 写入记录三条 exact 路由,卸载时一起释放。
|
|
81
130
|
*/
|
|
82
131
|
export declare function installDreamWeb(ctx: any, options?: DreamWebOptions): void;
|
package/lib/web.js
CHANGED
|
@@ -6,25 +6,28 @@
|
|
|
6
6
|
* 三道门缺一不可 ——
|
|
7
7
|
* 1. remoteAddress 若是明确的非回环地址则拒绝(局域网不可达);
|
|
8
8
|
* 2. Host 头必须是 localhost 名(挡 DNS rebinding:恶意域名解析到 127.0.0.1);
|
|
9
|
-
* 3. 只接受 GET
|
|
9
|
+
* 3. 只接受 GET:三条路由都没有任何写路径,所以不需要 CSRF 那一套。
|
|
10
10
|
*
|
|
11
|
-
* 零写盘纪律(M1):知识路由只读 <journalDir>/knowledge
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 零写盘纪律(M1/M2):知识路由只读 <journalDir>/knowledge/,写入记录路由只读
|
|
12
|
+
* <journalDir>/bridge/。目录不存在时直接返回空结果,绝不为了读去 mkdir 或 rebuildIndex;
|
|
13
|
+
* 重建索引只发生在写路径或显式维护操作里。
|
|
14
14
|
*
|
|
15
15
|
* @module dsh-dream/web
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
18
|
+
import { isAbsolute, join, win32 } from 'node:path';
|
|
19
|
+
import * as bridgeModule from './bridge.js';
|
|
19
20
|
import { resolveConfig } from './config.js';
|
|
20
21
|
import { dreamStats, readDreams, searchDreams } from './journal.js';
|
|
21
22
|
import { KnowledgeStore } from './knowledge-store.js';
|
|
22
|
-
import { maskSecrets } from './mask.js';
|
|
23
|
+
import { isDreamReference, maskSecrets } from './mask.js';
|
|
23
24
|
import { buildEvidenceSummary, lessonScopeLabel } from './retrieval.js';
|
|
24
25
|
/** 面板与浏览器说话的同源路由。 */
|
|
25
26
|
export const DREAM_ROUTE = '/_dsh/dsh-dream/journal';
|
|
26
27
|
/** M1 经验面板的只读路由。 */
|
|
27
28
|
export const DREAM_KNOWLEDGE_ROUTE = '/_dsh/dsh-dream/knowledge';
|
|
29
|
+
/** M2 写入记录面板的只读路由。 */
|
|
30
|
+
export const DREAM_BRIDGE_ROUTE = '/_dsh/dsh-dream/bridge';
|
|
28
31
|
/** ?limit 的默认值与上下界。 */
|
|
29
32
|
export const DREAM_LIMIT_DEFAULT = 50;
|
|
30
33
|
export const DREAM_LIMIT_MIN = 1;
|
|
@@ -71,6 +74,12 @@ export function knowledgeDirOf(options, journalDir = journalDirOf(options)) {
|
|
|
71
74
|
return options.knowledgeDir;
|
|
72
75
|
return join(journalDir, 'knowledge');
|
|
73
76
|
}
|
|
77
|
+
/** 解析后的 bridgeDir:显式路径优先,否则 = <journalDir>/bridge。 */
|
|
78
|
+
export function bridgeDirOf(options, journalDir = journalDirOf(options)) {
|
|
79
|
+
if (typeof options.bridgeDir === 'string' && options.bridgeDir.trim() !== '')
|
|
80
|
+
return options.bridgeDir;
|
|
81
|
+
return join(journalDir, 'bridge');
|
|
82
|
+
}
|
|
74
83
|
/** 解析请求 URL;非法或缺失时退回路由本身(等价于没有查询参数)。 */
|
|
75
84
|
function requestUrl(raw, fallback = DREAM_ROUTE) {
|
|
76
85
|
const text = typeof raw === 'string' && raw !== '' ? raw : fallback;
|
|
@@ -125,16 +134,28 @@ function requestGate(req, res, readOnlyMessage) {
|
|
|
125
134
|
}
|
|
126
135
|
return false;
|
|
127
136
|
}
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
/** 哈希字段不做二次脱敏:它们是校验值,不是密钥(面板要展示前 12 位)。 */
|
|
138
|
+
const HASH_KEY_RE = /^(?:sourceHash|beforeSha256|afterSha256|afterBlockSha256|afterFileSha256|sha256)$/;
|
|
139
|
+
const REFERENCE_KEYS = new Set(['id', 'lessonId', 'lessonIds', 'evidenceIds', 'conflictIds', 'supersedes', 'targetId', 'affectedIds', 'backupId', 'previewId']);
|
|
140
|
+
const PATH_KEYS = new Set(['target', 'path', 'resolvedPath', 'workspaceRoot']);
|
|
141
|
+
/** 递归脱敏;结构化绝对路径按目录分段,避免 Unix 分隔符把正常路径连成长令牌。 */
|
|
142
|
+
export function maskDeep(value, key) {
|
|
143
|
+
if (typeof value === 'string') {
|
|
144
|
+
if (key !== undefined && HASH_KEY_RE.test(key) && /^[0-9a-f]{64}$/i.test(value))
|
|
145
|
+
return value;
|
|
146
|
+
if (key !== undefined && REFERENCE_KEYS.has(key) && isDreamReference(value))
|
|
147
|
+
return value;
|
|
148
|
+
if (key !== undefined && PATH_KEYS.has(key) && (isAbsolute(value) || win32.isAbsolute(value)) && !/[\x00-\x1f]/.test(value)) {
|
|
149
|
+
return value.split(/([\\/]+)/).map((part) => maskSecrets(part)).join('');
|
|
150
|
+
}
|
|
131
151
|
return maskSecrets(value);
|
|
152
|
+
}
|
|
132
153
|
if (Array.isArray(value))
|
|
133
|
-
return value.map((item) => maskDeep(item));
|
|
154
|
+
return value.map((item) => maskDeep(item, key));
|
|
134
155
|
if (value !== null && typeof value === 'object') {
|
|
135
156
|
const out = {};
|
|
136
|
-
for (const [
|
|
137
|
-
out[
|
|
157
|
+
for (const [entryKey, item] of Object.entries(value))
|
|
158
|
+
out[entryKey] = maskDeep(item, entryKey);
|
|
138
159
|
return out;
|
|
139
160
|
}
|
|
140
161
|
return value;
|
|
@@ -173,6 +194,86 @@ export function emptyKnowledgePayload() {
|
|
|
173
194
|
stats: normalizeKnowledgeStats(null),
|
|
174
195
|
};
|
|
175
196
|
}
|
|
197
|
+
function asDisplayString(value) {
|
|
198
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
199
|
+
}
|
|
200
|
+
function asDisplayRevision(value) {
|
|
201
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 归一化桥接应用记录:只保留展示需要的字段,坏形状跳过。
|
|
205
|
+
* 数据源由 src/bridge.ts 负责(本层不做 IO、不重算可否回滚)。
|
|
206
|
+
*/
|
|
207
|
+
export function normalizeBridgeApplications(value) {
|
|
208
|
+
if (!Array.isArray(value))
|
|
209
|
+
return [];
|
|
210
|
+
const records = [];
|
|
211
|
+
for (const raw of value) {
|
|
212
|
+
if (raw === null || typeof raw !== 'object')
|
|
213
|
+
continue;
|
|
214
|
+
const item = raw;
|
|
215
|
+
const backupId = asDisplayString(item.backupId);
|
|
216
|
+
const target = asDisplayString(item.target);
|
|
217
|
+
if (backupId === '' && target === '')
|
|
218
|
+
continue;
|
|
219
|
+
const lessons = [];
|
|
220
|
+
if (Array.isArray(item.lessons)) {
|
|
221
|
+
for (const entry of item.lessons) {
|
|
222
|
+
if (entry !== null && typeof entry === 'object') {
|
|
223
|
+
const ref = entry;
|
|
224
|
+
const lessonId = asDisplayString(ref.lessonId);
|
|
225
|
+
if (lessonId === '')
|
|
226
|
+
continue;
|
|
227
|
+
lessons.push({ lessonId, revision: asDisplayRevision(ref.revision) });
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const text = asDisplayString(entry);
|
|
231
|
+
if (text === '')
|
|
232
|
+
continue;
|
|
233
|
+
const match = /^(.*)@(\d+)$/.exec(text);
|
|
234
|
+
if (match !== null)
|
|
235
|
+
lessons.push({ lessonId: match[1], revision: Number(match[2]) });
|
|
236
|
+
else
|
|
237
|
+
lessons.push({ lessonId: text, revision: 0 });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const record = {
|
|
242
|
+
backupId,
|
|
243
|
+
at: asDisplayString(item.at),
|
|
244
|
+
target,
|
|
245
|
+
action: asDisplayString(item.action),
|
|
246
|
+
lessons,
|
|
247
|
+
beforeSha256: asDisplayString(item.beforeSha256),
|
|
248
|
+
afterSha256: asDisplayString(item.afterSha256),
|
|
249
|
+
rollbackable: item.rollbackable === true,
|
|
250
|
+
};
|
|
251
|
+
const reason = asDisplayString(item.rollbackReason);
|
|
252
|
+
if (reason !== '')
|
|
253
|
+
record.rollbackReason = reason;
|
|
254
|
+
records.push(record);
|
|
255
|
+
}
|
|
256
|
+
return records;
|
|
257
|
+
}
|
|
258
|
+
/** 写入记录目录不存在时的空载荷。 */
|
|
259
|
+
export function emptyBridgePayload() {
|
|
260
|
+
return {
|
|
261
|
+
records: [],
|
|
262
|
+
stats: { total: 0, rollbackable: 0 },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 从桥接存储层取原始记录(按 at 降序,只读零写盘)。
|
|
267
|
+
* 契约来源:M2-A,`listBridgeApplications(journalDir): BridgeApplicationView[]`;
|
|
268
|
+
* 未落地 / 未导出时安全返回空,路由不受影响。
|
|
269
|
+
*/
|
|
270
|
+
function readBridgeApplicationsFromStore(journalDir) {
|
|
271
|
+
const candidate = bridgeModule.listBridgeApplications;
|
|
272
|
+
if (typeof candidate !== 'function')
|
|
273
|
+
return [];
|
|
274
|
+
const result = candidate(journalDir);
|
|
275
|
+
return Array.isArray(result) ? result : [];
|
|
276
|
+
}
|
|
176
277
|
/** 组装梦境日记路由处理器;journalDir 在这一刻定下来(与插件的配置生命周期一致)。 */
|
|
177
278
|
export function createDreamWebHandler(options = {}) {
|
|
178
279
|
const journalDir = journalDirOf(options);
|
|
@@ -192,6 +293,39 @@ export function createDreamWebHandler(options = {}) {
|
|
|
192
293
|
}
|
|
193
294
|
};
|
|
194
295
|
}
|
|
296
|
+
/** 组装只读写入记录路由处理器;零写盘:目录不存在就直接返回空结果。 */
|
|
297
|
+
export function createBridgeWebHandler(options = {}) {
|
|
298
|
+
const journalDir = journalDirOf(options);
|
|
299
|
+
const bridgeDir = bridgeDirOf(options, journalDir);
|
|
300
|
+
return async (req, res) => {
|
|
301
|
+
if (requestGate(req, res, 'dsh-dream bridge route is read-only; use GET'))
|
|
302
|
+
return;
|
|
303
|
+
try {
|
|
304
|
+
let raw;
|
|
305
|
+
if (options.bridgeStoreFactory !== undefined) {
|
|
306
|
+
raw = options.bridgeStoreFactory(bridgeDir).listApplications();
|
|
307
|
+
}
|
|
308
|
+
else if (existsSync(bridgeDir)) {
|
|
309
|
+
raw = readBridgeApplicationsFromStore(journalDir);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
raw = [];
|
|
313
|
+
}
|
|
314
|
+
const records = normalizeBridgeApplications(raw).map((record) => maskDeep(record));
|
|
315
|
+
const payload = {
|
|
316
|
+
records,
|
|
317
|
+
stats: {
|
|
318
|
+
total: records.length,
|
|
319
|
+
rollbackable: records.filter((record) => record.rollbackable).length,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
responseJson(res, 200, payload);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
internalError(res, error);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
195
329
|
/** 组装只读经验路由处理器;零写盘:目录不存在就直接返回空结果。 */
|
|
196
330
|
export function createKnowledgeWebHandler(options = {}) {
|
|
197
331
|
const knowledgeDir = knowledgeDirOf(options);
|
|
@@ -228,7 +362,7 @@ export function createKnowledgeWebHandler(options = {}) {
|
|
|
228
362
|
/**
|
|
229
363
|
* 把只读路由挂到宿主 webserver 上。
|
|
230
364
|
* 与 dsh-calendar 相同:ctx.inject(['webServer']) + effect 注册,插件卸载即摘掉路由;
|
|
231
|
-
* 同一个 effect
|
|
365
|
+
* 同一个 effect 里注册日记 / 知识 / 写入记录三条 exact 路由,卸载时一起释放。
|
|
232
366
|
*/
|
|
233
367
|
export function installDreamWeb(ctx, options = {}) {
|
|
234
368
|
if (ctx === null || ctx === undefined || typeof ctx.inject !== 'function') {
|
|
@@ -236,6 +370,7 @@ export function installDreamWeb(ctx, options = {}) {
|
|
|
236
370
|
}
|
|
237
371
|
const journalHandler = createDreamWebHandler(options);
|
|
238
372
|
const knowledgeHandler = createKnowledgeWebHandler(options);
|
|
373
|
+
const bridgeHandler = createBridgeWebHandler(options);
|
|
239
374
|
ctx.inject(['webServer'], (webCtx) => {
|
|
240
375
|
webCtx.effect(() => {
|
|
241
376
|
const disposers = [
|
|
@@ -249,6 +384,11 @@ export function installDreamWeb(ctx, options = {}) {
|
|
|
249
384
|
path: DREAM_KNOWLEDGE_ROUTE,
|
|
250
385
|
handler: (req, res) => knowledgeHandler(req, res),
|
|
251
386
|
}, 'dsh-dream: knowledge route'),
|
|
387
|
+
webCtx.webServer.register({
|
|
388
|
+
kind: 'exact',
|
|
389
|
+
path: DREAM_BRIDGE_ROUTE,
|
|
390
|
+
handler: (req, res) => bridgeHandler(req, res),
|
|
391
|
+
}, 'dsh-dream: bridge log route'),
|
|
252
392
|
];
|
|
253
393
|
return () => {
|
|
254
394
|
for (const dispose of disposers) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardustlc/dsh-dream",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "DSH 做梦与经验记忆:核验来源、审阅经验,预览写入 AGENTS.md 并支持回滚;附梦境日记面板,默认隐私脱敏,零运行时依赖。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -40,7 +40,7 @@ description: 做梦协议:任务开始取回适用经验,长任务收尾沉
|
|
|
40
40
|
- `verification`:`read` = 你或插件读到了原记录;`claimed` = 仅模型声称。**claimed 只计入证据列表,不计入独立支持数。**
|
|
41
41
|
- **只有真的读到来源(会话记录 / 本地验证产物 / 用户明确纠正)才能标 read,自我判断一律 claimed**("我记得""应该是"都算 claimed);标错 read 等于自我提权,禁止。
|
|
42
42
|
- 没有证据的观察只能是 candidate,不能写成确定事实,也不能说成「已验证」。
|
|
43
|
-
-
|
|
43
|
+
- 所有新经验都先进入 candidate,包括已读到的用户纠正。read 只表示插件成功核对来源,不表示用户采纳;工具审阅记录 actor:model,不能冒充真人。会话证据须给 sessionId、recordSeq、可匹配 quote(缺省使用 summary);本地产物须给工作目录内的 artifactPath。插件独立核对项目、角色和原文,计算 sourceHash;失败降为 claimed 并给 verificationReason。
|
|
44
44
|
- 用户明确采纳/驳回、发现经验互相冲突、依赖升级后旧经验需要复核 → 用 `dream_review`(必须带 `expectedRevision`)。
|
|
45
45
|
- 用户采纳必须来自真实用户指令;模型自己不能冒充 `actor: user`。
|
|
46
46
|
- 同一证据重复提交是幂等的,不会增加独立支持数。**禁止靠反复调用 `dream_save` 或 `dream_learn` 刷次数**——次数不等于正确,也不等于重要。
|
|
@@ -49,7 +49,8 @@ description: 做梦协议:任务开始取回适用经验,长任务收尾沉
|
|
|
49
49
|
|
|
50
50
|
- 只沉淀规律与偏好,不复述流水账;`reflection` 用第一人称写你的观察与感受;
|
|
51
51
|
- `lessons` 是 1–5 条可执行教训,每条以动词开头;拿不准就标注「存疑」;
|
|
52
|
-
- **不得把主观反思升级成 AGENTS.md 指令**:不要用 `dream_bridge`
|
|
52
|
+
- **不得把主观反思升级成 AGENTS.md 指令**:不要用 `dream_bridge` 把未经审阅的感悟写成项目规则。桥接只能用于 `usable`、当前项目范围(或显式 global 且 review.decision 为 accepted)、附证据的经验;频次不再作为准入条件。
|
|
53
|
+
- **桥接三步(M2)**:`mode:"preview"` 只读返回 diff / 参与经验 / skipped 原因(零写入)→ 人工复核 → `mode:"apply"` 必须带预览的 `expectedSha256`(新文件为 null),建议同时带 `previewId`(文件被并发修改则 `conflict` 且零写入);`mode:"rollback"` 只在当前管理块仍等于该次 apply 结果时回滚。缺省不传 `mode` 仍是 0.5.x 直写并返回弃用提示;新流程一律显式 preview → apply。写入内容统一过 `mask.ts`。
|
|
53
54
|
|
|
54
55
|
## 何时做梦
|
|
55
56
|
|
|
@@ -62,6 +63,8 @@ description: 做梦协议:任务开始取回适用经验,长任务收尾沉
|
|
|
62
63
|
- 想在日记里找原话 → `dream_recall`;
|
|
63
64
|
- 想取少量可复用经验 → `dream_context`(带范围与预算,只读);
|
|
64
65
|
- 想查看/审阅候选、处理冲突 → `dream_review`;
|
|
66
|
+
- attach-evidence 只补充核验后的证据,不自动采纳;resolve-conflict 必须给 resolution、affectedIds、note,整组原子更新;reopen 将驳回经验重新送审。
|
|
67
|
+
|
|
65
68
|
- 面板查看:「设置 → 梦境日记」里有只读的梦境时间线与「经验」区块(状态、适用条件、证据摘要、最近核验时间),面板不提供任何写操作。
|
|
66
69
|
|
|
67
70
|
---
|
|
@@ -103,7 +106,7 @@ Dreams and **lessons** are two separate pipelines: a **first-person reflection**
|
|
|
103
106
|
- `verification`: `read` = the record was actually read; `claimed` = the model merely asserts it. **Claimed evidence does not count toward independent support.**
|
|
104
107
|
- **Only mark read when the source was actually read (session record / local artifact / explicit user correction); anything self-asserted is claimed.** Marking read without a source is self-promotion and is forbidden.
|
|
105
108
|
- An observation without evidence stays a candidate: do not present it as a fact, and never call it "verified".
|
|
106
|
-
-
|
|
109
|
+
- Every new lesson starts as a candidate, including verified user corrections. Read means the plugin checked the source, not that a user accepted the lesson; tool review records actor:model. Session evidence needs sessionId, recordSeq and a matching quote (summary if omitted). Artifacts need artifactPath inside the working directory. The plugin checks project, role and text and computes sourceHash; failures become claimed with verificationReason.
|
|
107
110
|
- For explicit acceptance/rejection, conflicts between lessons, or re-validation after dependency upgrades → call `dream_review` with an `expectedRevision`.
|
|
108
111
|
- User acceptance must come from a real user instruction; the model must not impersonate `actor: user`.
|
|
109
112
|
- Submitting the same evidence twice is idempotent and does not increase independent support. **Never grind counts by calling `dream_save` or `dream_learn` repeatedly** — frequency is neither correctness nor importance.
|
|
@@ -112,7 +115,8 @@ Dreams and **lessons** are two separate pipelines: a **first-person reflection**
|
|
|
112
115
|
|
|
113
116
|
- Consolidate patterns and preferences only — never a running transcript. Write `reflection` in the first person.
|
|
114
117
|
- `lessons` are 1–5 actionable items; mark uncertain ones as "tentative".
|
|
115
|
-
- **Never promote a subjective reflection into an AGENTS.md instruction**: do not use `dream_bridge` to write unreviewed reflections into project rules. Bridging is for
|
|
118
|
+
- **Never promote a subjective reflection into an AGENTS.md instruction**: do not use `dream_bridge` to write unreviewed reflections into project rules. Bridging is for `usable`, project-scoped (or explicit global with review.decision accepted), evidence-backed lessons only; frequency is no longer an admission criterion.
|
|
119
|
+
- **Bridge in three steps (M2)**: `mode:"preview"` returns the diff / selected lessons / skipped reasons read-only (zero writes) → human review → `mode:"apply"` with the preview’s required `expectedSha256` (null for a new file); include `previewId` to bind the preview (a concurrent edit yields `conflict` and zero writes); `mode:"rollback"` only rolls back while the current managed block still equals that apply result. Omitting `mode` keeps the 0.5.x direct write and returns a deprecation notice; new flows should always use preview → apply. Everything written is masked via `mask.ts`.
|
|
116
120
|
|
|
117
121
|
## When to dream
|
|
118
122
|
|
|
@@ -125,4 +129,5 @@ Dreams and **lessons** are two separate pipelines: a **first-person reflection**
|
|
|
125
129
|
- Looking for exact words in the journal → `dream_recall`;
|
|
126
130
|
- Retrieving a few reusable lessons → `dream_context` (scoped, budgeted, read-only);
|
|
127
131
|
- Browsing or reviewing candidates and conflicts → `dream_review`;
|
|
132
|
+
- attach-evidence adds verified sources without promoting state. resolve-conflict requires resolution, affectedIds and note and updates the group atomically. reopen sends a rejected lesson back for review.
|
|
128
133
|
- To browse: **Settings → Dream journal** shows the read-only dream timeline and a **Lessons** block (state, applicability, evidence summary, last validated time). The panel has no write actions.
|