@modusensus/dsh-mneme 0.1.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/LICENSE +21 -0
- package/README.md +179 -0
- package/lib/api.js +59 -0
- package/lib/client.js +187 -0
- package/lib/config.js +15 -0
- package/lib/dream/decisions.js +121 -0
- package/lib/dream.js +205 -0
- package/lib/index.js +86 -0
- package/lib/inject.js +22 -0
- package/lib/mirror.js +131 -0
- package/lib/service.js +157 -0
- package/lib/store.js +230 -0
- package/lib/summarize.js +171 -0
- package/lib/tools.js +221 -0
- package/package.json +32 -0
- package/src/api.js +59 -0
- package/src/config.js +15 -0
- package/src/dream/decisions.js +121 -0
- package/src/dream.js +205 -0
- package/src/index.js +86 -0
- package/src/inject.js +22 -0
- package/src/mirror.js +131 -0
- package/src/service.js +157 -0
- package/src/store.js +230 -0
- package/src/summarize.js +171 -0
- package/src/tools.js +221 -0
package/src/api.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
|
|
3
|
+
function sendJson(res, status, payload) {
|
|
4
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
5
|
+
res.end(JSON.stringify(payload));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createApi(ctx, service) {
|
|
9
|
+
const disposers = [];
|
|
10
|
+
|
|
11
|
+
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
12
|
+
disposers.push(ctx.webServer.register({
|
|
13
|
+
kind: "prefix",
|
|
14
|
+
path: "/api/dsh-mneme",
|
|
15
|
+
handler(req, res) {
|
|
16
|
+
sendJson(res, 404, { error: "not-found" });
|
|
17
|
+
}
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
disposers.push(ctx.webServer.register({
|
|
21
|
+
kind: "exact",
|
|
22
|
+
path: "/api/dsh-mneme/list",
|
|
23
|
+
handler(req, res) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(req.url, "http://localhost");
|
|
26
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
27
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
28
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
29
|
+
const items = service.toApiList(service.list({ type, limit, offset }));
|
|
30
|
+
sendJson(res, 200, { items, total: service.count(type) });
|
|
31
|
+
} catch {
|
|
32
|
+
sendJson(res, 500, { error: "internal" });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
disposers.push(ctx.webServer.register({
|
|
38
|
+
kind: "exact",
|
|
39
|
+
path: "/api/dsh-mneme/search",
|
|
40
|
+
handler(req, res) {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(req.url, "http://localhost");
|
|
43
|
+
const q = url.searchParams.get("q") ?? "";
|
|
44
|
+
const limit = Number(url.searchParams.get("limit") ?? 20);
|
|
45
|
+
const items = service.toApiList(service.search(q, { limit }));
|
|
46
|
+
sendJson(res, 200, { items });
|
|
47
|
+
} catch {
|
|
48
|
+
sendJson(res, 500, { error: "internal" });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
routes: 3,
|
|
55
|
+
dispose: () => {
|
|
56
|
+
for (const dispose of disposers) dispose();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
|
|
3
|
+
export const Config = z.object({
|
|
4
|
+
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
|
+
autoInject: z.boolean().default(true),
|
|
6
|
+
autoSummarize: z.boolean().default(true),
|
|
7
|
+
maxInjectedItems: z.natural().min(1).max(20).default(5),
|
|
8
|
+
importanceThreshold: z.natural().min(1).max(5).default(3),
|
|
9
|
+
autoDream: z.boolean().default(true),
|
|
10
|
+
dreamThresholdCount: z.natural().min(1).max(1000).default(10),
|
|
11
|
+
dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
|
|
12
|
+
dreamDelayMs: z.natural().min(0).max(60000).default(2000),
|
|
13
|
+
dreamProvider: z.string(),
|
|
14
|
+
dreamModel: z.string()
|
|
15
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate a dream decision list against a snapshot of eligible memories.
|
|
5
|
+
* @param decisions - LLM-produced decision list.
|
|
6
|
+
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
7
|
+
* @returns {{ok: boolean, errors: string[]}}
|
|
8
|
+
*/
|
|
9
|
+
export function validateDecisions(decisions, snapshot) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
12
|
+
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
13
|
+
}
|
|
14
|
+
const claimed = new Set();
|
|
15
|
+
for (const [index, d] of decisions.entries()) {
|
|
16
|
+
const at = `decision[${index}]`;
|
|
17
|
+
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
18
|
+
errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
22
|
+
if (d.action === "conflict") {
|
|
23
|
+
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
24
|
+
errors.push(`${at}: conflict needs distinct winner and loser`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
28
|
+
errors.push(`${at}: ${d.action} needs non-empty ids`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const id of ids) {
|
|
32
|
+
const mem = snapshot.get(id);
|
|
33
|
+
if (!mem) {
|
|
34
|
+
errors.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
35
|
+
} else if (mem.archived || mem.type === "summary") {
|
|
36
|
+
errors.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
37
|
+
}
|
|
38
|
+
if (claimed.has(id)) {
|
|
39
|
+
errors.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
40
|
+
}
|
|
41
|
+
claimed.add(id);
|
|
42
|
+
}
|
|
43
|
+
if (d.action === "merge") {
|
|
44
|
+
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
45
|
+
errors.push(`${at}: merge keepSource must be one of ids`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
48
|
+
errors.push(`${at}: merge needs non-empty title and content`);
|
|
49
|
+
}
|
|
50
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
51
|
+
errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
52
|
+
}
|
|
53
|
+
// Merging across types would blur preference/project/decision boundaries
|
|
54
|
+
// in the injected context; the snapshot carries each entry's type.
|
|
55
|
+
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
56
|
+
if (mergeTypes.size > 1) {
|
|
57
|
+
errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Every snapshot id must appear in at least one decision
|
|
62
|
+
for (const id of snapshot.keys()) {
|
|
63
|
+
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
64
|
+
}
|
|
65
|
+
return { ok: errors.length === 0, errors };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Apply a validated decision list to the service. Caller must validate first.
|
|
70
|
+
*
|
|
71
|
+
* Note: merge is intentionally non-atomic — the keeper is updated before the
|
|
72
|
+
* other sources are archived, so a failure between the two never loses content.
|
|
73
|
+
*
|
|
74
|
+
* @param decisions - validated decision list.
|
|
75
|
+
* @param service - memory service (saveWithDedupe/getById/update/setArchived).
|
|
76
|
+
* @param logger - optional logger ({ warn }); per-decision failures are logged.
|
|
77
|
+
* @returns number of applied decisions (archive counts each archived memory as one).
|
|
78
|
+
*/
|
|
79
|
+
export function applyDecisions(decisions, service, logger = null) {
|
|
80
|
+
let applied = 0;
|
|
81
|
+
for (const [i, d] of decisions.entries()) {
|
|
82
|
+
try {
|
|
83
|
+
if (d.action === "keep") continue;
|
|
84
|
+
if (d.action === "archive") {
|
|
85
|
+
for (const id of d.ids) {
|
|
86
|
+
const mem = service.getById(id);
|
|
87
|
+
if (mem && !mem.archived) { service.setArchived(id, true); applied++; }
|
|
88
|
+
}
|
|
89
|
+
} else if (d.action === "merge") {
|
|
90
|
+
const keeper = service.getById(d.keepSource);
|
|
91
|
+
if (!keeper || keeper.archived) continue;
|
|
92
|
+
service.update(d.keepSource, {
|
|
93
|
+
title: d.title,
|
|
94
|
+
content: d.content,
|
|
95
|
+
importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
|
|
96
|
+
});
|
|
97
|
+
for (const id of d.ids) {
|
|
98
|
+
if (id !== d.keepSource) {
|
|
99
|
+
const mem = service.getById(id);
|
|
100
|
+
if (mem && !mem.archived) { service.setArchived(id, true); }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
applied++;
|
|
104
|
+
} else if (d.action === "conflict") {
|
|
105
|
+
const winner = service.getById(d.winner);
|
|
106
|
+
const loser = service.getById(d.loser);
|
|
107
|
+
if (!winner || !loser) continue;
|
|
108
|
+
service.update(d.winner, {
|
|
109
|
+
content: `${winner.content}\n\n(已否决旧信息:${[...loser.content].slice(0, 100).join("")})`
|
|
110
|
+
});
|
|
111
|
+
service.setArchived(d.loser, true);
|
|
112
|
+
applied++;
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Skip individual bad decision; never corrupt the store. The optional
|
|
116
|
+
// logger makes the failure visible instead of failing silently.
|
|
117
|
+
logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return applied;
|
|
121
|
+
}
|
package/src/dream.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
|
+
export { validateDecisions, applyDecisions };
|
|
3
|
+
|
|
4
|
+
const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
|
|
5
|
+
|
|
6
|
+
const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
|
|
7
|
+
请执行记忆巩固(consolidation):
|
|
8
|
+
1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
|
|
9
|
+
2. 识别重复/过时信息 → 输出 archive
|
|
10
|
+
3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
|
|
11
|
+
4. 无问题的条目 → 输出 keep
|
|
12
|
+
|
|
13
|
+
规则:
|
|
14
|
+
- 每条记忆至少出现在一个决策中
|
|
15
|
+
- merge 的 keepSource 必须是 ids 之一
|
|
16
|
+
- 仅合并同类型条目(type 相同)
|
|
17
|
+
- 不要编造 ids;只使用提供的 id
|
|
18
|
+
- 重要性 1-5,合并后取最高
|
|
19
|
+
- 只输出 JSON 数组,不要其他文字`;
|
|
20
|
+
|
|
21
|
+
function totalChars(memories) {
|
|
22
|
+
return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Consume an LLM stream and return the accumulated text. Direct text-delta
|
|
27
|
+
* accumulation covers both the real protocol ({type:"text-delta", index, text})
|
|
28
|
+
* and looser test doubles ({type:"text-delta", text}); a terminal error/abort
|
|
29
|
+
* surfaces as undefined. The caller decides how to treat an empty result.
|
|
30
|
+
*/
|
|
31
|
+
async function streamText(ctx, options) {
|
|
32
|
+
let text = "";
|
|
33
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
34
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
35
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the LLM route: agent default model (deployment) first, plugin config
|
|
44
|
+
* (dreamProvider/dreamModel) as fallback. Falls through to undefined when no
|
|
45
|
+
* route exists — runDream then fails safe. Fallback is logged so a silent
|
|
46
|
+
* route switch is observable.
|
|
47
|
+
*/
|
|
48
|
+
function resolveRoute(ctx, config, logger) {
|
|
49
|
+
try {
|
|
50
|
+
const sel = ctx.agentDefaultModel?.currentSelection?.();
|
|
51
|
+
if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
|
|
52
|
+
logger?.warn?.("dsh-mneme dream: agentDefaultModel unavailable, falling back to config route");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
logger?.warn?.(`dsh-mneme dream: agentDefaultModel lookup failed, falling back to config route: ${String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger }) {
|
|
61
|
+
let pendingTimer = null;
|
|
62
|
+
let running = false;
|
|
63
|
+
let disposed = false;
|
|
64
|
+
let baseline = { count: 0, chars: 0 };
|
|
65
|
+
|
|
66
|
+
function shouldTrigger(service) {
|
|
67
|
+
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
68
|
+
const count = memories.length;
|
|
69
|
+
const chars = totalChars(memories);
|
|
70
|
+
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
71
|
+
const overAbs = count >= thresholdCount || chars >= thresholdChars;
|
|
72
|
+
return { trigger: overAbs && overBase, count, chars };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function maybeSchedule(service) {
|
|
76
|
+
if (disposed || running || pendingTimer) return false;
|
|
77
|
+
const { trigger, count, chars } = shouldTrigger(service);
|
|
78
|
+
if (!trigger) return false;
|
|
79
|
+
pendingTimer = setTimeout(() => {
|
|
80
|
+
pendingTimer = null;
|
|
81
|
+
running = true;
|
|
82
|
+
// Defer the onRun invocation so a synchronous throw cannot escape the
|
|
83
|
+
// timer callback (which would crash the process) and skip the teardown.
|
|
84
|
+
// Errors are logged, never swallowed silently.
|
|
85
|
+
Promise.resolve()
|
|
86
|
+
.then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
|
|
87
|
+
.then((result) => {
|
|
88
|
+
// Refresh the baseline only for a successful run (design §5.3: an
|
|
89
|
+
// LLM failure must not move the baseline, so the next write can
|
|
90
|
+
// immediately re-trigger a retry). A `{ok:false}` result or a throw
|
|
91
|
+
// keeps the old baseline. A run that reports nothing is treated as
|
|
92
|
+
// completed without failure (no-op hooks / minimal test doubles).
|
|
93
|
+
if (result && result.ok) {
|
|
94
|
+
try {
|
|
95
|
+
baseline = shouldTrigger(service);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Store closed mid-flight: keep the last known baseline.
|
|
98
|
+
logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
.catch((error) => {
|
|
103
|
+
logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
|
|
104
|
+
// Failed runs do not refresh the baseline.
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
running = false;
|
|
108
|
+
});
|
|
109
|
+
}, delayMs);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dispose() {
|
|
114
|
+
disposed = true;
|
|
115
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
116
|
+
// An in-flight run is left to complete naturally: its LLM calls are
|
|
117
|
+
// already paid for and aborting would discard the work. The caller is
|
|
118
|
+
// responsible for closing the store only after the run has finished.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runDream(ctx, service, config) {
|
|
122
|
+
const logger = ctx.logger;
|
|
123
|
+
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
124
|
+
if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
|
|
125
|
+
const snapshot = new Map(memories.map((m) => [m.id, m]));
|
|
126
|
+
const route = resolveRoute(ctx, config, logger);
|
|
127
|
+
if (!route) {
|
|
128
|
+
logger?.warn?.("dsh-mneme dream: no llm route available");
|
|
129
|
+
return { ok: false, error: "no llm route", summary: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const listText = [...snapshot.values()].map((m) =>
|
|
133
|
+
`id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`
|
|
134
|
+
).join("\n");
|
|
135
|
+
|
|
136
|
+
let decisionText;
|
|
137
|
+
try {
|
|
138
|
+
decisionText = await streamText(ctx, {
|
|
139
|
+
provider: route.provider,
|
|
140
|
+
model: route.model,
|
|
141
|
+
purpose: "compaction",
|
|
142
|
+
maxTokens: config.dreamMaxTokens ?? 4096,
|
|
143
|
+
messages: [
|
|
144
|
+
{ role: "system", content: [{ type: "text", text: CONSOLIDATION_PROMPT }] },
|
|
145
|
+
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
|
|
150
|
+
return { ok: false, error: "llm failed", summary: false };
|
|
151
|
+
}
|
|
152
|
+
if (decisionText === undefined) {
|
|
153
|
+
logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
|
|
154
|
+
return { ok: false, error: "llm failed", summary: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let decisions;
|
|
158
|
+
try {
|
|
159
|
+
const start = decisionText.indexOf("[");
|
|
160
|
+
const end = decisionText.lastIndexOf("]");
|
|
161
|
+
if (start === -1 || end <= start) {
|
|
162
|
+
logger?.warn?.("dsh-mneme dream: no json array in llm output");
|
|
163
|
+
return { ok: false, error: "no json array in llm output", summary: false };
|
|
164
|
+
}
|
|
165
|
+
decisions = JSON.parse(decisionText.slice(start, end + 1));
|
|
166
|
+
} catch {
|
|
167
|
+
logger?.warn?.("dsh-mneme dream: invalid decisions json");
|
|
168
|
+
return { ok: false, error: "invalid decisions json", summary: false };
|
|
169
|
+
}
|
|
170
|
+
const { ok, errors } = validateDecisions(decisions, snapshot);
|
|
171
|
+
if (!ok) {
|
|
172
|
+
logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
|
|
173
|
+
return { ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const applied = applyDecisions(decisions, service, logger);
|
|
177
|
+
|
|
178
|
+
// Summary generation (second LLM call). A throwing stream is reported as
|
|
179
|
+
// a failed run; summary:false marks a run that produced no summary.
|
|
180
|
+
let summaryText;
|
|
181
|
+
try {
|
|
182
|
+
summaryText = await streamText(ctx, {
|
|
183
|
+
provider: route.provider,
|
|
184
|
+
model: route.model,
|
|
185
|
+
purpose: "compaction",
|
|
186
|
+
maxTokens: config.dreamMaxTokens ?? 2048,
|
|
187
|
+
messages: [
|
|
188
|
+
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
189
|
+
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
190
|
+
]
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
|
|
194
|
+
return { ok: false, error: "llm failed", summary: false };
|
|
195
|
+
}
|
|
196
|
+
let summaryStored = false;
|
|
197
|
+
if (summaryText !== undefined && summaryText.trim()) {
|
|
198
|
+
service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: summaryText.trim(), importance: 5, source: "dream" });
|
|
199
|
+
summaryStored = true;
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, applied, summary: summaryStored };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { maybeSchedule, runDream, dispose };
|
|
205
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createStore } from "./store.js";
|
|
2
|
+
import { createMirror, TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { createService } from "./service.js";
|
|
4
|
+
import { createTools } from "./tools.js";
|
|
5
|
+
import { createInjector } from "./inject.js";
|
|
6
|
+
import { createSummarizer } from "./summarize.js";
|
|
7
|
+
import { createDreamScheduler } from "./dream.js";
|
|
8
|
+
import { createApi } from "./api.js";
|
|
9
|
+
import { Config } from "./config.js";
|
|
10
|
+
import { mkdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
|
|
14
|
+
export const name = "dsh-mneme";
|
|
15
|
+
export const inject = ["tools", "systemPrompt", "webServer", "llm", "agentDefaultModel"];
|
|
16
|
+
export { Config };
|
|
17
|
+
|
|
18
|
+
// Arrow (not function declaration): cordis 4 treats any apply with a
|
|
19
|
+
// prototype as a class constructor (`new apply(...)`) and discards its return
|
|
20
|
+
// value, so a `function apply` disposer would never run on unload. An arrow
|
|
21
|
+
// has no prototype, is called normally, and its returned disposer is collected
|
|
22
|
+
// and run by the fiber on unload.
|
|
23
|
+
export const apply = (ctx, config) => {
|
|
24
|
+
const cfg = Config(config);
|
|
25
|
+
|
|
26
|
+
// Resolve memoryDir: expand leading "~"
|
|
27
|
+
const memoryDir = cfg.memoryDir.startsWith("~")
|
|
28
|
+
? join(homedir(), cfg.memoryDir.slice(1))
|
|
29
|
+
: cfg.memoryDir;
|
|
30
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const store = createStore(join(memoryDir, "memory.db"));
|
|
33
|
+
const mirror = createMirror(memoryDir);
|
|
34
|
+
const service = createService({ store, mirror, config: cfg });
|
|
35
|
+
|
|
36
|
+
// Human edits in mirror files win on every sync; merge them back first.
|
|
37
|
+
// TYPE_FILE maps each memory type to its mirror filename.
|
|
38
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
39
|
+
const edits = mirror.readHumanEdits(type);
|
|
40
|
+
if (edits.length) service.mergeHumanEdits(type, edits);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Dream scheduler: automatic consolidation + summary runs, triggered by
|
|
44
|
+
// store growth. Writes through the service fire the dream hook, which asks
|
|
45
|
+
// the scheduler to (re)schedule a run once absolute and since-last-run
|
|
46
|
+
// thresholds are both exceeded. onRun is deferred through `dream` so the
|
|
47
|
+
// closure sees the assigned scheduler; the null guard keeps a run safe even
|
|
48
|
+
// if the hook fires before assignment or after dispose.
|
|
49
|
+
let dream = null;
|
|
50
|
+
if (cfg.autoDream) {
|
|
51
|
+
dream = createDreamScheduler({
|
|
52
|
+
thresholdCount: cfg.dreamThresholdCount,
|
|
53
|
+
thresholdChars: cfg.dreamThresholdChars,
|
|
54
|
+
delayMs: cfg.dreamDelayMs,
|
|
55
|
+
logger: ctx.logger,
|
|
56
|
+
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
|
|
57
|
+
});
|
|
58
|
+
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const disposers = [];
|
|
62
|
+
|
|
63
|
+
ctx.inject(["systemPrompt"], (promptCtx) => {
|
|
64
|
+
if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, cfg));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
ctx.inject(["tools"], (toolsCtx) => {
|
|
68
|
+
disposers.push(createTools(toolsCtx, service, cfg));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const summarizer = createSummarizer(ctx, service, cfg);
|
|
72
|
+
disposers.push(summarizer.dispose);
|
|
73
|
+
|
|
74
|
+
if (ctx.webServer) {
|
|
75
|
+
const api = createApi(ctx, service);
|
|
76
|
+
disposers.push(api.dispose);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
for (const dispose of disposers) {
|
|
81
|
+
if (typeof dispose === "function") dispose();
|
|
82
|
+
}
|
|
83
|
+
if (dream) dream.dispose();
|
|
84
|
+
store.close();
|
|
85
|
+
};
|
|
86
|
+
};
|
package/src/inject.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createInjector(ctx, service, config) {
|
|
2
|
+
const maxItems = config.maxInjectedItems ?? 5;
|
|
3
|
+
const threshold = config.importanceThreshold ?? 3;
|
|
4
|
+
|
|
5
|
+
function render(candidates) {
|
|
6
|
+
if (!candidates.length) return "";
|
|
7
|
+
const lines = ["[记忆库] 来自 dsh-mneme 的跨会话记忆(用户偏好与高优先级项目/决策):"];
|
|
8
|
+
for (const m of candidates) {
|
|
9
|
+
lines.push(`- [${m.type}] ${m.title}(重要性 ${m.importance}):${m.content}`);
|
|
10
|
+
}
|
|
11
|
+
return lines.join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return ctx.systemPrompt.context({
|
|
15
|
+
name: "memory",
|
|
16
|
+
order: 90,
|
|
17
|
+
text: () => {
|
|
18
|
+
const candidates = service.injectCandidates({ maxItems, threshold });
|
|
19
|
+
return render(candidates);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
package/src/mirror.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TYPE_FILE = {
|
|
5
|
+
preference: "preferences.md",
|
|
6
|
+
project: "projects.md",
|
|
7
|
+
decision: "decisions.md",
|
|
8
|
+
history: "history.md",
|
|
9
|
+
summary: "summary.md"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
13
|
+
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
14
|
+
|
|
15
|
+
function esc(text) {
|
|
16
|
+
return String(text).replace(ESCAPE, "\\$1");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unescape(text) {
|
|
20
|
+
return String(text).replace(UNESCAPE, "$1");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderMemory(m) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
lines.push(`## ${esc(m.title)}`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`- **ID**: \`${m.id}\``);
|
|
28
|
+
lines.push(`- **类型**: ${m.type}`);
|
|
29
|
+
lines.push(`- **重要性**: ${m.importance}`);
|
|
30
|
+
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
31
|
+
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
32
|
+
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(m.content);
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push("---");
|
|
37
|
+
lines.push("");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMirror(dir) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
function filePath(type) {
|
|
45
|
+
const name = TYPE_FILE[type];
|
|
46
|
+
return name ? join(dir, name) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
51
|
+
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
52
|
+
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
53
|
+
* spans from its ID line up to the next ID line (or end of file). The block
|
|
54
|
+
* head (the ID line plus the generated metadata run) and the trailing
|
|
55
|
+
* structural "---" separator are stripped; everything in between is the entry
|
|
56
|
+
* body, so user content containing "---", metadata-like lines, or even a
|
|
57
|
+
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
58
|
+
* heading preceding the ID line.
|
|
59
|
+
*/
|
|
60
|
+
function readHumanEdits(type = undefined) {
|
|
61
|
+
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
62
|
+
const edits = [];
|
|
63
|
+
for (const t of types) {
|
|
64
|
+
const file = filePath(t);
|
|
65
|
+
if (!file || !existsSync(file)) continue;
|
|
66
|
+
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
67
|
+
// Anchor on the ID line only when it is a structural entry head: the
|
|
68
|
+
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
69
|
+
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
70
|
+
// or produces a ghost entry.
|
|
71
|
+
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
72
|
+
let prevEnd = 0;
|
|
73
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
74
|
+
const anchor = anchors[i];
|
|
75
|
+
const blockStart = anchor.index;
|
|
76
|
+
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
77
|
+
|
|
78
|
+
// Title: last "## " heading before this ID line (file header region /
|
|
79
|
+
// previous block tail). Body headings of earlier entries come before
|
|
80
|
+
// the structural "---" + "## " of this entry, so the last match wins.
|
|
81
|
+
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
82
|
+
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
83
|
+
|
|
84
|
+
// Body: the ID line and the generated metadata run are structural head;
|
|
85
|
+
// everything after them up to the trailing "---" separator is the body.
|
|
86
|
+
let body = text
|
|
87
|
+
.slice(blockStart, blockEnd)
|
|
88
|
+
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
89
|
+
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "");
|
|
90
|
+
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
91
|
+
const lastSep = separators[separators.length - 1];
|
|
92
|
+
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
|
+
body = body.trim();
|
|
94
|
+
|
|
95
|
+
edits.push({
|
|
96
|
+
id: anchor[1],
|
|
97
|
+
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
+
content: body
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const lineEnd = text.indexOf("\n", blockStart);
|
|
102
|
+
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return edits;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sync(memories) {
|
|
109
|
+
const byType = {};
|
|
110
|
+
for (const m of memories) {
|
|
111
|
+
(byType[m.type] ??= []).push(m);
|
|
112
|
+
}
|
|
113
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
114
|
+
const file = filePath(type);
|
|
115
|
+
const items = (byType[type] ?? [])
|
|
116
|
+
.slice()
|
|
117
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
118
|
+
if (items.length === 0) {
|
|
119
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
120
|
+
// memories do not "resurrect" via readHumanEdits
|
|
121
|
+
rmSync(file, { force: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
125
|
+
const body = items.map(renderMemory).join("\n");
|
|
126
|
+
writeFileSync(file, header + body, "utf8");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { filePath, sync, readHumanEdits };
|
|
131
|
+
}
|