jbc-dssi 1.0.0 → 1.0.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/index.js +23 -3
- package/package.json +6 -2
- package/vendor/bots/bridge/.gitkeep +0 -0
- package/vendor/bots/bridge/index.js +16 -0
- package/vendor/bots/bridge/meta.js +112 -0
- package/vendor/bots/bridge/view.js +207 -0
- package/vendor/bots/contract/.gitkeep +0 -0
- package/vendor/bots/contract/fixtures/team-list.json +165 -0
- package/vendor/bots/contract/teams.schema.json +78 -0
- package/vendor/dssAgent/bridge/distill.js +300 -0
- package/vendor/dssAgent/bridge/index.js +2719 -0
- package/vendor/groups/bridge/engine.js +477 -0
- package/vendor/groups/bridge/index.js +299 -0
- package/vendor/groups/bridge/mentions.js +66 -0
- package/vendor/groups/bridge/prompt.js +67 -0
- package/vendor/groups/bridge/state.js +65 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提炼记忆 —— 把一段对话文本变成若干篇记忆文档,并重建索引。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成一个模块:它只做"文本 → 文件"这一件事,不碰桥接协议,
|
|
5
|
+
* 可以直接用 node 单测;协议层(index.js)只负责收包、鉴权、回包。
|
|
6
|
+
*
|
|
7
|
+
* 落盘格式严格按 UniMemory 契约(13 字段契约头 + 正文自由写)。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
const API_URL = 'https://api.deepseek.com/chat/completions';
|
|
14
|
+
const MODEL = 'deepseek-chat';
|
|
15
|
+
const MAX_INPUT_CHARS = 120000;
|
|
16
|
+
const MAX_ITEMS = 8;
|
|
17
|
+
|
|
18
|
+
/** 从 ~/.dsh/.credentials.yaml 里取 DeepSeek API key(只读 refs 段)。 */
|
|
19
|
+
export async function readApiKey() {
|
|
20
|
+
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
|
21
|
+
const file = path.join(os.homedir(), '.dsh', '.credentials.yaml');
|
|
22
|
+
const text = await fs.readFile(file, 'utf8');
|
|
23
|
+
const m = /DEEPSEEK_API_KEY:\s*(\S+)/u.exec(text);
|
|
24
|
+
return m ? m[1] : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 生成 UUIDv7(时间有序),不引依赖。 */
|
|
28
|
+
export function uuidv7(now = Date.now()) {
|
|
29
|
+
const hex = now.toString(16).padStart(12, '0');
|
|
30
|
+
const rand = () => Math.floor(Math.random() * 16).toString(16);
|
|
31
|
+
const r = (n) => Array.from({ length: n }, rand).join('');
|
|
32
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${r(3)}-${'89ab'[Math.floor(Math.random() * 4)]}${r(3)}-${r(12)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isoNow() {
|
|
36
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000);
|
|
37
|
+
return d.toISOString().replace('Z', '+08:00').replace(/\.\d{3}/u, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SYSTEM_PROMPT = `你是记忆整理器。用户会给你一段对话,请你从中提炼出值得长期记住的信息。
|
|
41
|
+
|
|
42
|
+
只输出一个 JSON 数组,不要任何解释、不要 markdown 代码块。每个元素:
|
|
43
|
+
{
|
|
44
|
+
"名字": "短标题,≤15 个汉字,能一眼看出这是什么",
|
|
45
|
+
"一句话总结": "≤40 个汉字",
|
|
46
|
+
"类型标签": ["记忆的性质,1-3 个,如 决策/事实/偏好/原则/习惯/教训"],
|
|
47
|
+
"内容标签": ["内容关键词,≤10 个,每个 ≤10 字符"],
|
|
48
|
+
"摘要": "≤100 字,名字和标签都没命中时作为兜底",
|
|
49
|
+
"正文": "记忆正文。完整、可独立看懂的 Markdown,不要写'如上所述'这类依赖上下文的表述"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
规则:
|
|
53
|
+
1. 按主题拆分,一件事一篇,不要把所有事塞进一篇;
|
|
54
|
+
2. 寒暄、过程描述、纠错来回、没有结论的讨论,不要提炼;
|
|
55
|
+
3. 低于 100 字且无法独立复述的片段,不要单独成篇;
|
|
56
|
+
4. 没有值得记的内容就输出空数组 [];
|
|
57
|
+
5. 最多 ${MAX_ITEMS} 条。`;
|
|
58
|
+
|
|
59
|
+
/** 调模型提炼,返回规范化后的条目数组(失败抛错)。 */
|
|
60
|
+
export async function distill({ text, apiKey, fetchImpl = fetch }) {
|
|
61
|
+
const input = String(text ?? '').trim();
|
|
62
|
+
if (!input) throw new Error('没有可提炼的内容');
|
|
63
|
+
if (input.length > MAX_INPUT_CHARS) {
|
|
64
|
+
throw new Error(`一次最多提炼 ${MAX_INPUT_CHARS} 字,请减少勾选的轮次`);
|
|
65
|
+
}
|
|
66
|
+
const resp = await fetchImpl(API_URL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
model: MODEL,
|
|
71
|
+
messages: [
|
|
72
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
73
|
+
{ role: 'user', content: input },
|
|
74
|
+
],
|
|
75
|
+
temperature: 0.2,
|
|
76
|
+
response_format: { type: 'json_object' },
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
if (!resp.ok) {
|
|
80
|
+
const body = await resp.text().catch(() => '');
|
|
81
|
+
throw new Error(`模型调用失败 HTTP ${resp.status} ${body.slice(0, 120)}`);
|
|
82
|
+
}
|
|
83
|
+
const data = await resp.json();
|
|
84
|
+
const raw = data?.choices?.[0]?.message?.content ?? '';
|
|
85
|
+
return normalizeItems(parseItems(raw));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 模型可能回数组、也可能回 {items:[...]};两种都认。 */
|
|
89
|
+
export function parseItems(raw) {
|
|
90
|
+
const text = String(raw ?? '').trim().replace(/^```(?:json)?/u, '').replace(/```$/u, '').trim();
|
|
91
|
+
let value;
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
const m = /\[[\s\S]*\]/u.exec(text);
|
|
96
|
+
if (m === null) return [];
|
|
97
|
+
value = JSON.parse(m[0]);
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(value)) return value;
|
|
100
|
+
if (Array.isArray(value?.items)) return value.items;
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 清洗 + 截断,并丢掉没有名字/正文的条目。 */
|
|
105
|
+
export function normalizeItems(items) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const it of items.slice(0, MAX_ITEMS)) {
|
|
108
|
+
const name = strip(String(it?.名字 ?? it?.title ?? '')).slice(0, 20);
|
|
109
|
+
const body = String(it?.正文 ?? it?.body ?? '').trim();
|
|
110
|
+
if (!name || !body) continue;
|
|
111
|
+
out.push({
|
|
112
|
+
name,
|
|
113
|
+
oneLine: strip(String(it?.一句话总结 ?? it?.summary ?? '')).slice(0, 50),
|
|
114
|
+
kinds: toTags(it?.类型标签, 5, 5).slice(0, 3),
|
|
115
|
+
topics: toTags(it?.内容标签, 20, 10).slice(0, 10),
|
|
116
|
+
abstract: strip(String(it?.摘要 ?? '')).slice(0, 100),
|
|
117
|
+
body,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function strip(s) {
|
|
124
|
+
return s.replace(/[\r\n]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toTags(value, max, per) {
|
|
128
|
+
const arr = Array.isArray(value) ? value : value ? [value] : [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const v of arr) {
|
|
131
|
+
const t = strip(String(v)).replace(/[,,、;;]/gu, '').slice(0, per);
|
|
132
|
+
if (t && !out.includes(t)) out.push(t);
|
|
133
|
+
if (out.length >= max) break;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 契约头(13 字段,顺序固定)。 */
|
|
139
|
+
export function renderDoc(item, uuid, now) {
|
|
140
|
+
const lines = [
|
|
141
|
+
'---',
|
|
142
|
+
`UUID: ${uuid}`,
|
|
143
|
+
`名字: ${item.name}`,
|
|
144
|
+
`一句话总结: ${item.oneLine || item.name}`,
|
|
145
|
+
'类型标签:',
|
|
146
|
+
...(item.kinds.length ? item.kinds.map((t) => ' - ' + t) : [' - 记忆']),
|
|
147
|
+
'内容标签:',
|
|
148
|
+
...(item.topics.length ? item.topics.map((t) => ' - ' + t) : [' - 未分类']),
|
|
149
|
+
`摘要: ${item.abstract || item.oneLine || item.name}`,
|
|
150
|
+
'初始权重: 80.00',
|
|
151
|
+
`创建时间: ${now}`,
|
|
152
|
+
`最新更新时间: ${now}`,
|
|
153
|
+
'最新权重: 80.00',
|
|
154
|
+
'文件外链: []',
|
|
155
|
+
'外链文件类型: []',
|
|
156
|
+
'外链文件摘要: []',
|
|
157
|
+
'---',
|
|
158
|
+
'',
|
|
159
|
+
'#' + ' ' + item.name,
|
|
160
|
+
'',
|
|
161
|
+
item.body,
|
|
162
|
+
'',
|
|
163
|
+
];
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 文件名 = 名字.md;重名追加 -2、-3。 */
|
|
168
|
+
export async function pickFileName(memoryDir, name) {
|
|
169
|
+
const safe = name.replace(/[/\\:*?"<>|\n]/gu, '').trim() || '记忆';
|
|
170
|
+
let candidate = `${safe}.md`;
|
|
171
|
+
let i = 2;
|
|
172
|
+
// eslint-disable-next-line no-await-in-loop
|
|
173
|
+
while (await exists(path.join(memoryDir, candidate))) {
|
|
174
|
+
candidate = `${safe}-${i}.md`;
|
|
175
|
+
i += 1;
|
|
176
|
+
}
|
|
177
|
+
return candidate;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function exists(p) {
|
|
181
|
+
try {
|
|
182
|
+
await fs.access(p);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function levelOf(w) {
|
|
190
|
+
if (w >= 90) return ['L1', '核心记忆'];
|
|
191
|
+
if (w >= 70) return ['L2', '高频记忆'];
|
|
192
|
+
if (w >= 50) return ['L3', '中频记忆'];
|
|
193
|
+
if (w >= 30) return ['L4', '低频记忆'];
|
|
194
|
+
if (w >= 10) return ['L5', '遗忘记忆'];
|
|
195
|
+
return ['L6', '休眠记忆'];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 解析中文契约头(只读本模块用到的字段)。 */
|
|
199
|
+
export function parseHeader(raw) {
|
|
200
|
+
const text = String(raw ?? '').replace(/\r\n/gu, '\n');
|
|
201
|
+
if (!text.startsWith('---\n')) return null;
|
|
202
|
+
const end = text.indexOf('\n---\n', 4);
|
|
203
|
+
if (end < 0) return null;
|
|
204
|
+
const head = {};
|
|
205
|
+
let listKey = null;
|
|
206
|
+
for (const line of text.slice(4, end).split('\n')) {
|
|
207
|
+
if (/^\s+-\s+/u.test(line) && listKey) {
|
|
208
|
+
head[listKey].push(line.replace(/^\s+-\s+/u, '').trim());
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const m = /^([^\s:][^:]*?):\s*(.*)$/u.exec(line.trim());
|
|
212
|
+
if (m === null) continue;
|
|
213
|
+
if (m[2] === '') {
|
|
214
|
+
head[m[1]] = [];
|
|
215
|
+
listKey = m[1];
|
|
216
|
+
} else {
|
|
217
|
+
head[m[1]] = m[2];
|
|
218
|
+
listKey = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { header: head, body: text.slice(end + 5).replace(/^\n+/u, '') };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 全量重建 index.json(与 uni.py 的产物同构)。 */
|
|
225
|
+
export async function rebuildIndex(entityDir) {
|
|
226
|
+
const memoryDir = path.join(entityDir, 'memory');
|
|
227
|
+
const rows = [];
|
|
228
|
+
async function walk(dir) {
|
|
229
|
+
let entries = [];
|
|
230
|
+
try {
|
|
231
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const e of entries) {
|
|
236
|
+
const full = path.join(dir, e.name);
|
|
237
|
+
if (e.isDirectory()) {
|
|
238
|
+
if (e.name === 'archive' || e.name === 'trash') continue;
|
|
239
|
+
// eslint-disable-next-line no-await-in-loop
|
|
240
|
+
await walk(full);
|
|
241
|
+
} else if (e.name.endsWith('.md') && e.name !== 'index.md') {
|
|
242
|
+
// eslint-disable-next-line no-await-in-loop
|
|
243
|
+
const raw = await fs.readFile(full, 'utf8').catch(() => '');
|
|
244
|
+
const parsed = parseHeader(raw);
|
|
245
|
+
if (parsed === null) continue;
|
|
246
|
+
const h = parsed.header;
|
|
247
|
+
const weight = Number.parseFloat(String(h['最新权重'] ?? h.importance ?? '0')) || 0;
|
|
248
|
+
const [code, lname] = levelOf(weight);
|
|
249
|
+
rows.push({
|
|
250
|
+
层次: code,
|
|
251
|
+
层次名: lname,
|
|
252
|
+
最新权重: Number(weight.toFixed(2)),
|
|
253
|
+
UUID: String(h.UUID ?? h.uuid ?? ''),
|
|
254
|
+
名字: String(h['名字'] ?? h.title ?? ''),
|
|
255
|
+
一句话总结: String(h['一句话总结'] ?? h.description ?? ''),
|
|
256
|
+
类型标签: Array.isArray(h['类型标签']) ? h['类型标签'] : [],
|
|
257
|
+
内容标签: Array.isArray(h['内容标签']) ? h['内容标签'] : [],
|
|
258
|
+
外链数: Array.isArray(h['文件外链']) ? h['文件外链'].length : 0,
|
|
259
|
+
摘要: String(h['摘要'] ?? ''),
|
|
260
|
+
路径: path.relative(entityDir, full),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await walk(memoryDir);
|
|
266
|
+
const order = { L1: 0, L2: 1, L3: 2, L4: 3, L5: 4, L6: 5 };
|
|
267
|
+
rows.sort((a, b) => (order[a.层次] - order[b.层次]) || (b.最新权重 - a.最新权重) || a.路径.localeCompare(b.路径));
|
|
268
|
+
const payload = { 生成时间: isoNow(), 记忆数: rows.length, 记录: rows };
|
|
269
|
+
const out = path.join(entityDir, 'index.json');
|
|
270
|
+
const tmp = `${out}.tmp-${process.pid}`;
|
|
271
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
272
|
+
await fs.rename(tmp, out);
|
|
273
|
+
return payload;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 主流程:提炼 → 逐篇落盘 → 重建索引。
|
|
278
|
+
* @returns {{created:number, titles:string[]}}
|
|
279
|
+
*/
|
|
280
|
+
export async function distillAndWrite({ entityDir, text, apiKey, fetchImpl }) {
|
|
281
|
+
const items = await distill({ text, apiKey, fetchImpl });
|
|
282
|
+
if (items.length === 0) return { created: 0, titles: [] };
|
|
283
|
+
const memoryDir = path.join(entityDir, 'memory');
|
|
284
|
+
await fs.mkdir(memoryDir, { recursive: true });
|
|
285
|
+
const now = isoNow();
|
|
286
|
+
const titles = [];
|
|
287
|
+
for (const item of items) {
|
|
288
|
+
// eslint-disable-next-line no-await-in-loop
|
|
289
|
+
const fileName = await pickFileName(memoryDir, item.name);
|
|
290
|
+
const doc = renderDoc(item, uuidv7(), now);
|
|
291
|
+
const target = path.join(memoryDir, fileName);
|
|
292
|
+
// eslint-disable-next-line no-await-in-loop
|
|
293
|
+
await fs.writeFile(`${target}.tmp-${process.pid}`, doc, 'utf8');
|
|
294
|
+
// eslint-disable-next-line no-await-in-loop
|
|
295
|
+
await fs.rename(`${target}.tmp-${process.pid}`, target);
|
|
296
|
+
titles.push(item.name);
|
|
297
|
+
}
|
|
298
|
+
await rebuildIndex(entityDir);
|
|
299
|
+
return { created: titles.length, titles };
|
|
300
|
+
}
|