dsh-trivium 0.2.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/lib/embed.js ADDED
@@ -0,0 +1,110 @@
1
+ /** Optional remote (OpenAI-compatible) embeddings. Default off. Failures degrade to zero vectors. */
2
+
3
+ import { DIM, zeroVector } from "./schema.js";
4
+
5
+ const DEFAULT_MODEL = "text-embedding-3-small";
6
+
7
+ let config = {
8
+ enabled: false,
9
+ url: "",
10
+ model: DEFAULT_MODEL,
11
+ apiKey: "",
12
+ };
13
+
14
+ /** Test hook. `(text) => number[] | null` */
15
+ let testEmbed = null;
16
+
17
+ export function configureEmbedding(opts = {}) {
18
+ const url = String(opts.embeddingUrl || process.env.DSH_TRIVIUM_EMBED_URL || "").trim();
19
+ const model = String(opts.embeddingModel || process.env.DSH_TRIVIUM_EMBED_MODEL || DEFAULT_MODEL).trim();
20
+ const apiKey = String(
21
+ opts.embeddingApiKey ||
22
+ process.env.DSH_TRIVIUM_EMBED_KEY ||
23
+ process.env.OPENAI_API_KEY ||
24
+ "",
25
+ ).trim();
26
+ config = {
27
+ enabled: opts.embeddingEnabled === true,
28
+ url,
29
+ model: model || DEFAULT_MODEL,
30
+ apiKey,
31
+ };
32
+ }
33
+
34
+ export function setTestEmbed(fn) {
35
+ testEmbed = typeof fn === "function" ? fn : null;
36
+ }
37
+
38
+ export function embeddingEnabled() {
39
+ if (testEmbed) return true;
40
+ return config.enabled === true && looksHttpUrl(config.url);
41
+ }
42
+
43
+ export function embeddingPublicStatus() {
44
+ return {
45
+ embeddingEnabled: config.enabled === true,
46
+ embeddingUrl: config.url,
47
+ embeddingModel: config.model || DEFAULT_MODEL,
48
+ embeddingApiKeySet: Boolean(config.apiKey),
49
+ embeddingReady: embeddingEnabled(),
50
+ };
51
+ }
52
+
53
+ export function looksHttpUrl(value) {
54
+ try {
55
+ const u = new URL(String(value || ""));
56
+ return u.protocol === "http:" || u.protocol === "https:";
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export function normalizeVector(vec) {
63
+ if (!Array.isArray(vec) || !vec.length) return null;
64
+ const out = [];
65
+ for (let i = 0; i < DIM; i += 1) {
66
+ const n = Number(vec[i]);
67
+ out.push(Number.isFinite(n) ? n : 0);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function vectorFromResponse(data) {
73
+ const row = data?.data?.[0] || data?.data || data;
74
+ const raw = row?.embedding || row?.vector || data?.embedding || data?.vector;
75
+ return normalizeVector(raw);
76
+ }
77
+
78
+ /** @returns {Promise<number[]|null>} null means caller should use keyword / zero vector */
79
+ export async function embedText(text) {
80
+ const input = String(text || "").replace(/\s+/g, " ").trim().slice(0, 8000);
81
+ if (!input) return null;
82
+ if (testEmbed) {
83
+ try {
84
+ return normalizeVector(await testEmbed(input));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ if (!embeddingEnabled()) return null;
90
+ try {
91
+ const headers = { "content-type": "application/json" };
92
+ if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`;
93
+ const ctrl = typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(15000) : undefined;
94
+ const resp = await fetch(config.url, {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify({ model: config.model, input }),
98
+ signal: ctrl,
99
+ });
100
+ if (!resp.ok) return null;
101
+ const data = await resp.json();
102
+ return vectorFromResponse(data);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function zeroOr(vec) {
109
+ return Array.isArray(vec) && vec.length === DIM ? vec : zeroVector();
110
+ }
package/lib/extract.js ADDED
@@ -0,0 +1,454 @@
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { EDGE_LABELS, clip, looksSecret, nowIso } from "./schema.js";
4
+ import {
5
+ findMergeTarget,
6
+ insertNode,
7
+ mergeInto,
8
+ ensureLink,
9
+ } from "./store.js";
10
+ import { parseUntilFromText, resolveUntilAt } from "./until.js";
11
+
12
+ export const PREFERENCE_CUES =
13
+ /记住|以后都|别再|不要再|从现在起|always do|from now on|never again|remember (?:this|that|to)\b/i;
14
+ export const DECISION_CUES =
15
+ /先别动|下周再|采用方案|就用[这那]个|不要改成|postpone|defer until|go with (?:option|plan|方案)/i;
16
+ export const ONESHOT_CUES =
17
+ /把这[个份]?文件|改一下.{0,40}文件|只改这|just (?:change|edit|fix) this (?:file|one)|only this file/i;
18
+ export const CHITCHAT_CUES =
19
+ /^(哈哈+|呵呵+|嗯+|哦+|好的|谢谢|天气|吃了吗|在吗)[\s!!.。??]*$/;
20
+
21
+ const NAME_RE =
22
+ /`([^`]{2,64})`|\b([A-Z][a-z]+[A-Z][A-Za-z0-9]*)\b|\b([A-Z][A-Za-z0-9]*(?:[_-][A-Z][A-Za-z0-9]*)+)\b|\b([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)\b|\b([A-Z]{3,}[A-Z0-9_-]{0,})\b/g;
23
+
24
+ const NAME_STOP = new Set([
25
+ "GET",
26
+ "PUT",
27
+ "POST",
28
+ "JSON",
29
+ "HTTP",
30
+ "HTTPS",
31
+ "HTML",
32
+ "CSS",
33
+ "TODO",
34
+ "NULL",
35
+ "TRUE",
36
+ "FALSE",
37
+ "AND",
38
+ "THE",
39
+ "THIS",
40
+ "THAT",
41
+ "FOR",
42
+ "CTX_FIND",
43
+ "CTX_READ",
44
+ "CTX_REMEMBER",
45
+ "CTX_LINK",
46
+ ]);
47
+
48
+ const TOOL_DRIVE_CUES = /只用 ctx_|贴工具原文|按顺序调用/;
49
+ const PATH_NOISE_NAME = /^(about|decided|fixed|broke|same_as|in_workspace)-\d+$/i;
50
+ const FOLDERISH_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-\d+$/i;
51
+
52
+ const WRITE_TYPES = new Set(["entity", "preference", "decision", "experience"]);
53
+
54
+ export function pendingPath(cwd) {
55
+ return join(cwd, ".dsh", "trivium-pending.json");
56
+ }
57
+
58
+ export function loadPending(cwd) {
59
+ try {
60
+ const raw = readFileSync(pendingPath(cwd), "utf8");
61
+ const data = JSON.parse(raw);
62
+ if (!data || !Array.isArray(data.turns)) return null;
63
+ return data;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function savePending(cwd, payload) {
70
+ const file = pendingPath(cwd);
71
+ mkdirSync(dirname(file), { recursive: true });
72
+ writeFileSync(
73
+ file,
74
+ JSON.stringify(
75
+ {
76
+ cwd,
77
+ savedAt: nowIso(),
78
+ ...payload,
79
+ },
80
+ null,
81
+ 2,
82
+ ),
83
+ "utf8",
84
+ );
85
+ }
86
+
87
+ export function clearPending(cwd) {
88
+ try {
89
+ unlinkSync(pendingPath(cwd));
90
+ } catch {
91
+ // missing is fine
92
+ }
93
+ }
94
+
95
+ export function textFromContent(content) {
96
+ if (typeof content === "string") return content;
97
+ if (!Array.isArray(content)) return "";
98
+ const parts = [];
99
+ for (const block of content) {
100
+ if (!block) continue;
101
+ if (block.type === "text" && block.text) parts.push(String(block.text));
102
+ else if (block.type === "tool-result" && Array.isArray(block.content)) {
103
+ parts.push(textFromContent(block.content));
104
+ } else if (typeof block.text === "string") parts.push(block.text);
105
+ }
106
+ return parts.join("\n");
107
+ }
108
+
109
+ function untilOf(text) {
110
+ return parseUntilFromText(text);
111
+ }
112
+
113
+ function isToolDriveTurn(text) {
114
+ return TOOL_DRIVE_CUES.test(String(text || ""));
115
+ }
116
+
117
+ function distillableTurns(turns) {
118
+ return (turns || []).filter((t) => t.role !== "user" || !isToolDriveTurn(t.text));
119
+ }
120
+
121
+ function isNoiseName(name) {
122
+ const s = String(name || "").trim();
123
+ if (!s) return true;
124
+ if (PATH_NOISE_NAME.test(s) || FOLDERISH_NAME.test(s)) return true;
125
+ if (/^session-[0-9a-f-]+$/i.test(s)) return true;
126
+ if (/^ctx[_-]?(find|read|remember|link)$/i.test(s)) return true;
127
+ return false;
128
+ }
129
+
130
+ function planNameOf(span) {
131
+ const m = String(span || "").match(/方案\s*([A-Za-z0-9甲乙丙丁一二三四五六七八九十])/u);
132
+ return m ? `方案 ${m[1]}` : undefined;
133
+ }
134
+
135
+ function cueSpans(text, re) {
136
+ const parts = String(text || "")
137
+ .split(/(?<=[。!?\n])|(?<=\. )/u)
138
+ .map((s) => s.trim())
139
+ .filter(Boolean);
140
+ const hits = parts.filter((p) => re.test(p));
141
+ return hits.length ? hits : [String(text || "").trim()].filter(Boolean);
142
+ }
143
+
144
+ function cueSpan(text, re) {
145
+ return cueSpans(text, re)[0] || String(text || "").trim();
146
+ }
147
+
148
+ function adjacentLinkName(span, turnText) {
149
+ const parts = String(turnText || "")
150
+ .split(/(?<=[。!?\n])|(?<=\. )/u)
151
+ .map((s) => s.trim())
152
+ .filter(Boolean);
153
+ const idx = parts.indexOf(span);
154
+ if (idx < 0) return undefined;
155
+ for (const neighbor of [parts[idx - 1], parts[idx + 1]]) {
156
+ if (!neighbor || PREFERENCE_CUES.test(neighbor) || DECISION_CUES.test(neighbor)) continue;
157
+ const name = collectNames(neighbor, 1)[0]?.name;
158
+ if (name) return name;
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ function linkNameOf(span, turnText, uniqueTurn, { inSpanOnly = false } = {}) {
164
+ const inSpan = collectNames(span, 1)[0]?.name;
165
+ if (inSpan) return inSpan;
166
+ if (inSpanOnly) return undefined;
167
+ return (
168
+ adjacentLinkName(span, turnText) ||
169
+ (uniqueTurn ? collectNames(turnText, 1)[0]?.name : undefined)
170
+ );
171
+ }
172
+
173
+ function collectNames(text, min = 2) {
174
+ const counts = new Map();
175
+ const s = String(text || "");
176
+ NAME_RE.lastIndex = 0;
177
+ let match;
178
+ while ((match = NAME_RE.exec(s))) {
179
+ const name = (match[1] || match[2] || match[3] || match[4] || match[5] || "").trim();
180
+ if (!name || NAME_STOP.has(name.toUpperCase()) || isNoiseName(name)) continue;
181
+ if (/^E[A-Z]{3,}$/.test(name)) continue;
182
+ if (name.length < 2) continue;
183
+ const key = name.toLowerCase();
184
+ const cur = counts.get(key) || { name, n: 0 };
185
+ cur.n += 1;
186
+ counts.set(key, cur);
187
+ }
188
+ return [...counts.values()].filter((row) => row.n >= min);
189
+ }
190
+
191
+ function turnsText(turns) {
192
+ return (turns || [])
193
+ .map((t) => `${t.role}: ${t.text || ""}`)
194
+ .join("\n");
195
+ }
196
+
197
+ function precedingUserText(usable, toolTurn) {
198
+ const idx = usable.indexOf(toolTurn);
199
+ const end = idx < 0 ? usable.length : idx;
200
+ for (let i = end - 1; i >= 0; i--) {
201
+ if (usable[i].role === "user") return String(usable[i].text || "");
202
+ }
203
+ return "";
204
+ }
205
+
206
+ export function ruleCandidates(turns) {
207
+ const out = [];
208
+ const usable = distillableTurns(turns);
209
+
210
+ for (const turn of usable) {
211
+ if (turn.role !== "user") continue;
212
+ const text = String(turn.text || "").trim();
213
+ if (!text || CHITCHAT_CUES.test(text)) continue;
214
+ const prefSpans = PREFERENCE_CUES.test(text) ? cueSpans(text, PREFERENCE_CUES) : [];
215
+ const decisionSpans = DECISION_CUES.test(text) ? cueSpans(text, DECISION_CUES) : [];
216
+ const uniqueTurn = prefSpans.length + decisionSpans.length <= 1;
217
+
218
+ for (const span of prefSpans) {
219
+ if (looksSecret(span)) continue;
220
+ if (ONESHOT_CUES.test(span) && !PREFERENCE_CUES.test(span)) continue;
221
+ out.push({
222
+ type: "preference",
223
+ text: clip(span, 240),
224
+ quote: clip(span, 120),
225
+ linkName: linkNameOf(span, text, uniqueTurn, { inSpanOnly: true }),
226
+ linkLabel: EDGE_LABELS.about,
227
+ via: "rule",
228
+ });
229
+ }
230
+
231
+ for (const span of decisionSpans) {
232
+ if (looksSecret(span) || ONESHOT_CUES.test(span)) continue;
233
+ const plan = planNameOf(span);
234
+ if (plan) {
235
+ out.push({
236
+ type: "entity",
237
+ name: plan,
238
+ text: plan,
239
+ via: "rule",
240
+ });
241
+ }
242
+ const object = linkNameOf(span, text, uniqueTurn) || plan;
243
+ out.push({
244
+ type: "decision",
245
+ name: object,
246
+ text: clip(span, 240),
247
+ until: untilOf(span),
248
+ linkName: object,
249
+ linkLabel: EDGE_LABELS.decided,
250
+ quote: clip(span, 120),
251
+ via: "rule",
252
+ });
253
+ }
254
+ }
255
+
256
+ for (const row of collectNames(turnsText(usable.filter((t) => t.role === "user")))) {
257
+ out.push({
258
+ type: "entity",
259
+ name: row.name,
260
+ text: row.name,
261
+ via: "rule",
262
+ });
263
+ }
264
+
265
+ const byTurn = new Map();
266
+ for (const turn of usable) {
267
+ if (turn.role !== "tool") continue;
268
+ const key = `${turn.turn ?? 0}:${turn.name || ""}`;
269
+ const list = byTurn.get(key) || [];
270
+ list.push(turn);
271
+ byTurn.set(key, list);
272
+ }
273
+ for (const list of byTurn.values()) {
274
+ const failed = list.find((t) => t.ok === false);
275
+ const ok = list.find((t) => t.ok === true);
276
+ if (!failed || !ok) continue;
277
+ const around = usable
278
+ .filter((t) => t.role === "user" && (t.turn === failed.turn || t.turn === ok.turn))
279
+ .map((t) => t.text)
280
+ .join(" ");
281
+ const nearby = precedingUserText(usable, failed);
282
+ const linkName =
283
+ collectNames(`${failed.text || ""} ${ok.text || ""}`, 1)[0]?.name ||
284
+ collectNames(around, 1)[0]?.name ||
285
+ collectNames(nearby, 1)[0]?.name;
286
+ out.push({
287
+ type: "experience",
288
+ name: failed.name || "tool",
289
+ text: `${failed.name || "tool"} failed then succeeded`,
290
+ fail: clip(failed.text, 160),
291
+ fix: clip(ok.text, 160),
292
+ linkName,
293
+ linkLabel: EDGE_LABELS.fixed,
294
+ via: "rule",
295
+ });
296
+ }
297
+
298
+ return out;
299
+ }
300
+
301
+ export function filterCandidates(candidates) {
302
+ const seen = new Set();
303
+ const out = [];
304
+ const caps = { entity: 8, preference: 8, decision: 6, experience: 4 };
305
+ const used = { entity: 0, preference: 0, decision: 0, experience: 0 };
306
+ for (const raw of candidates || []) {
307
+ if (!raw || !WRITE_TYPES.has(raw.type)) continue;
308
+ const text = String(raw.text || raw.name || "").trim();
309
+ if (!text) continue;
310
+ if (raw.type === "entity" && isNoiseName(raw.name || text)) continue;
311
+ if (looksSecret(text) || looksSecret(raw.fail) || looksSecret(raw.fix) || looksSecret(raw.quote)) {
312
+ continue;
313
+ }
314
+ if (raw.type === "preference") {
315
+ const blob = `${raw.text || ""} ${raw.quote || ""}`;
316
+ if (CHITCHAT_CUES.test(String(raw.text || "").trim())) continue;
317
+ if (ONESHOT_CUES.test(blob) && !PREFERENCE_CUES.test(blob)) continue;
318
+ if (!PREFERENCE_CUES.test(blob)) continue;
319
+ }
320
+ if ((used[raw.type] || 0) >= (caps[raw.type] || 4)) continue;
321
+ const key = `${raw.type}|${(raw.name || "").toLowerCase()}|${text.toLowerCase()}`;
322
+ if (seen.has(key)) continue;
323
+ seen.add(key);
324
+ used[raw.type] = (used[raw.type] || 0) + 1;
325
+ out.push({
326
+ type: raw.type,
327
+ name: raw.name ? String(raw.name).slice(0, 80) : undefined,
328
+ text: clip(text, 400),
329
+ linkName: raw.linkName ? String(raw.linkName).slice(0, 80) : undefined,
330
+ linkLabel: raw.linkLabel || undefined,
331
+ fail: raw.fail ? clip(raw.fail, 200) : undefined,
332
+ fix: raw.fix ? clip(raw.fix, 200) : undefined,
333
+ until: raw.until ? String(raw.until).slice(0, 40) : undefined,
334
+ quote: raw.quote ? clip(raw.quote, 160) : undefined,
335
+ aliases: Array.isArray(raw.aliases) ? raw.aliases.map(String).slice(0, 8) : undefined,
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+
341
+ export function parseModelJson(raw) {
342
+ const text = String(raw || "").trim();
343
+ if (!text) return [];
344
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
345
+ const body = fence ? fence[1] : text;
346
+ const start = body.indexOf("{");
347
+ const end = body.lastIndexOf("}");
348
+ if (start < 0 || end <= start) return [];
349
+ try {
350
+ const parsed = JSON.parse(body.slice(start, end + 1));
351
+ const list = Array.isArray(parsed?.candidates)
352
+ ? parsed.candidates
353
+ : Array.isArray(parsed)
354
+ ? parsed
355
+ : [];
356
+ return list.map((item) => ({ ...item, via: "model" }));
357
+ } catch {
358
+ return [];
359
+ }
360
+ }
361
+
362
+ export function buildExtractPrompt(turns) {
363
+ const transcript = clip(turnsText(turns), 2400);
364
+ return [
365
+ "Extract durable memory candidates as JSON only.",
366
+ 'Shape: {"candidates":[{"type":"preference|entity|decision|experience","name":"","text":"","linkName":"","linkLabel":"about|decided|broke|fixed","fail":"","fix":"","until":"","quote":""}]}',
367
+ "Whitelist: 记住/以后都/别再 → preference; repeated proper names → entity; decision with an object (先别动/下周再/采用方案) → decision; tool fail then success in the same turn → experience.",
368
+ "Do not write chitchat or one-off file edits as preference. Do not store secrets, tool dumps, or whole conversations.",
369
+ "If nothing matches, return {\"candidates\":[]}.",
370
+ "",
371
+ transcript,
372
+ ].join("\n");
373
+ }
374
+
375
+ function resolveEntityId(db, name) {
376
+ if (!name) return null;
377
+ return findMergeTarget(db, { type: "entity", name, text: name });
378
+ }
379
+
380
+ export function applyCandidates(db, candidates, { sessionId } = {}) {
381
+ const list = filterCandidates(candidates);
382
+ const ordered = [
383
+ ...list.filter((c) => c.type === "entity"),
384
+ ...list.filter((c) => c.type !== "entity"),
385
+ ];
386
+ const applied = [];
387
+ for (const cand of ordered) {
388
+ const existing = findMergeTarget(db, cand);
389
+ const payload = {
390
+ type: cand.type,
391
+ name: cand.name,
392
+ text: cand.text,
393
+ uri: `ctx://${cand.type}/${Date.now()}`,
394
+ aliases: cand.aliases,
395
+ fail: cand.fail,
396
+ fix: cand.fix,
397
+ until: cand.until,
398
+ source: {
399
+ sessionId: sessionId ? String(sessionId) : "",
400
+ quote: cand.quote,
401
+ via: "extract",
402
+ },
403
+ };
404
+ const untilAt = cand.until ? resolveUntilAt(cand.until) : undefined;
405
+ if (untilAt) payload.untilAt = untilAt;
406
+ let id;
407
+ let action = "insert";
408
+ if (existing != null) {
409
+ id = mergeInto(db, existing, payload);
410
+ action = "merge";
411
+ } else {
412
+ id = insertNode(db, payload);
413
+ }
414
+ const linkName = cand.linkName;
415
+ if (linkName && cand.type !== "entity") {
416
+ const target = resolveEntityId(db, linkName);
417
+ if (target != null && target !== id) {
418
+ const label =
419
+ cand.linkLabel ||
420
+ (cand.type === "decision"
421
+ ? EDGE_LABELS.decided
422
+ : cand.type === "experience"
423
+ ? EDGE_LABELS.fixed
424
+ : EDGE_LABELS.about);
425
+ try {
426
+ ensureLink(db, id, target, label);
427
+ } catch {
428
+ // edge is optional
429
+ }
430
+ }
431
+ }
432
+ applied.push({ id, action, type: cand.type, text: cand.text });
433
+ }
434
+ return applied;
435
+ }
436
+
437
+ export async function distill({ db, turns, sessionId, llmCall, log }) {
438
+ const usable = distillableTurns(turns);
439
+ if (!usable.length) return { applied: [], llmFailed: false };
440
+ const rules = ruleCandidates(usable);
441
+ let model = [];
442
+ let llmFailed = false;
443
+ if (typeof llmCall === "function") {
444
+ try {
445
+ const raw = await llmCall(buildExtractPrompt(usable));
446
+ model = parseModelJson(raw);
447
+ } catch (err) {
448
+ llmFailed = true;
449
+ log?.warn?.(`[dsh-trivium] extract prompt failed: ${err.message}`);
450
+ }
451
+ }
452
+ const applied = applyCandidates(db, [...rules, ...model], { sessionId });
453
+ return { applied, llmFailed, ruleCount: rules.length, modelCount: model.length };
454
+ }