@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/lib/store.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
const SCHEMA = `
|
|
5
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
type TEXT NOT NULL,
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
content TEXT NOT NULL,
|
|
10
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
11
|
+
importance INTEGER NOT NULL DEFAULT 3,
|
|
12
|
+
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
source TEXT,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
updated_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
23
|
+
|
|
24
|
+
// Pure helpers: no shared module state.
|
|
25
|
+
|
|
26
|
+
function sanitizePage(limit, offset, defaultLimit) {
|
|
27
|
+
const lim = Number.isInteger(limit) && limit > 0 ? limit : defaultLimit;
|
|
28
|
+
const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
|
|
29
|
+
return { limit: lim, offset: off };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeLike(q) {
|
|
33
|
+
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTags(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const arr = JSON.parse(raw);
|
|
39
|
+
return Array.isArray(arr) ? arr : [];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toRow(row) {
|
|
46
|
+
if (!row) return undefined;
|
|
47
|
+
return {
|
|
48
|
+
id: row.id,
|
|
49
|
+
type: row.type,
|
|
50
|
+
title: row.title,
|
|
51
|
+
content: row.content,
|
|
52
|
+
tags: parseTags(row.tags),
|
|
53
|
+
importance: row.importance,
|
|
54
|
+
forgotten: row.forgotten === 1,
|
|
55
|
+
archived: row.archived === 1,
|
|
56
|
+
source: row.source ?? undefined,
|
|
57
|
+
created_at: row.created_at,
|
|
58
|
+
updated_at: row.updated_at
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createStore(path) {
|
|
63
|
+
const db = new DatabaseSync(path);
|
|
64
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
65
|
+
db.exec(SCHEMA);
|
|
66
|
+
|
|
67
|
+
// Schema migration: add archived column to legacy databases (idempotent)
|
|
68
|
+
const columns = db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
69
|
+
if (!columns.includes("archived")) {
|
|
70
|
+
db.exec("ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
74
|
+
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
75
|
+
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
76
|
+
let lastTs = "";
|
|
77
|
+
function nowIso() {
|
|
78
|
+
let ts = new Date().toISOString();
|
|
79
|
+
if (lastTs && ts <= lastTs) {
|
|
80
|
+
const d = new Date(lastTs);
|
|
81
|
+
d.setMilliseconds(d.getMilliseconds() + 1);
|
|
82
|
+
ts = d.toISOString();
|
|
83
|
+
}
|
|
84
|
+
lastTs = ts;
|
|
85
|
+
return ts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function count(type, { includeForgotten = false, includeArchived = false } = {}) {
|
|
89
|
+
const clauses = [];
|
|
90
|
+
const params = [];
|
|
91
|
+
if (type !== undefined) {
|
|
92
|
+
clauses.push("type = ?");
|
|
93
|
+
params.push(type);
|
|
94
|
+
}
|
|
95
|
+
if (!includeForgotten) {
|
|
96
|
+
clauses.push("forgotten = 0");
|
|
97
|
+
}
|
|
98
|
+
if (!includeArchived) {
|
|
99
|
+
clauses.push("archived = 0");
|
|
100
|
+
}
|
|
101
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
102
|
+
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getById(id) {
|
|
106
|
+
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
107
|
+
return toRow(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function save(memory) {
|
|
111
|
+
const id = memory.id ?? randomUUID();
|
|
112
|
+
const type = memory.type;
|
|
113
|
+
if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
|
|
114
|
+
if (memory.tags !== undefined && !Array.isArray(memory.tags)) {
|
|
115
|
+
throw new Error("tags must be an array");
|
|
116
|
+
}
|
|
117
|
+
const now = nowIso();
|
|
118
|
+
const tags = JSON.stringify(memory.tags ?? []);
|
|
119
|
+
const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
|
|
120
|
+
db.prepare(
|
|
121
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, created_at, updated_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`
|
|
123
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, now, now);
|
|
124
|
+
return getById(id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function update(id, patch) {
|
|
128
|
+
const existing = getById(id);
|
|
129
|
+
if (!existing) throw new Error(`memory not found: ${id}`);
|
|
130
|
+
const type = patch.type ?? existing.type;
|
|
131
|
+
if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
|
|
132
|
+
if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
|
|
133
|
+
throw new Error("tags must be an array");
|
|
134
|
+
}
|
|
135
|
+
const now = nowIso();
|
|
136
|
+
db.prepare(
|
|
137
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, updated_at=? WHERE id=?`
|
|
138
|
+
).run(
|
|
139
|
+
type,
|
|
140
|
+
patch.title ?? existing.title,
|
|
141
|
+
patch.content ?? existing.content,
|
|
142
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
143
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
144
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
145
|
+
now,
|
|
146
|
+
id
|
|
147
|
+
);
|
|
148
|
+
return getById(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function remove(id) {
|
|
152
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setForget(id, forgotten) {
|
|
156
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
157
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
158
|
+
return getById(id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function setArchived(id, archived) {
|
|
162
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
163
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
164
|
+
return getById(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
|
|
168
|
+
const clauses = [];
|
|
169
|
+
const params = [];
|
|
170
|
+
if (type) {
|
|
171
|
+
clauses.push("type = ?");
|
|
172
|
+
params.push(type);
|
|
173
|
+
}
|
|
174
|
+
if (!includeForgotten) {
|
|
175
|
+
clauses.push("forgotten = 0");
|
|
176
|
+
}
|
|
177
|
+
if (!includeArchived) {
|
|
178
|
+
clauses.push("archived = 0");
|
|
179
|
+
}
|
|
180
|
+
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
181
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
182
|
+
const rows = db.prepare(
|
|
183
|
+
`SELECT * FROM memories ${where} ORDER BY importance DESC, updated_at DESC, id LIMIT ? OFFSET ?`
|
|
184
|
+
).all(...params, lim, off);
|
|
185
|
+
return rows.map(toRow);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function all() {
|
|
189
|
+
const rows = db.prepare("SELECT * FROM memories ORDER BY updated_at DESC").all();
|
|
190
|
+
return rows.map(toRow);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
194
|
+
const q = String(query).trim();
|
|
195
|
+
if (!q) return [];
|
|
196
|
+
// FTS5 over unicode61 (English + long phrases); LIKE fallback covers CJK substring.
|
|
197
|
+
// LIKE wildcards in the query are escaped so user input is matched literally.
|
|
198
|
+
const like = `%${escapeLike(q)}%`;
|
|
199
|
+
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
200
|
+
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
201
|
+
const rows = db.prepare(
|
|
202
|
+
`SELECT * FROM memories
|
|
203
|
+
WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
204
|
+
ORDER BY
|
|
205
|
+
CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
|
|
206
|
+
importance DESC,
|
|
207
|
+
updated_at DESC,
|
|
208
|
+
id
|
|
209
|
+
LIMIT ?`
|
|
210
|
+
).all(like, like, like, like, lim);
|
|
211
|
+
return rows.map(toRow);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
db,
|
|
216
|
+
count,
|
|
217
|
+
getById,
|
|
218
|
+
save,
|
|
219
|
+
update,
|
|
220
|
+
remove,
|
|
221
|
+
setForget,
|
|
222
|
+
setArchived,
|
|
223
|
+
list,
|
|
224
|
+
all,
|
|
225
|
+
search,
|
|
226
|
+
close() {
|
|
227
|
+
db.close();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
package/lib/summarize.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
|
|
3
|
+
const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
|
|
4
|
+
只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
|
|
5
|
+
不要输出任何其他文字。`;
|
|
6
|
+
|
|
7
|
+
/** Extract a JSON array from LLM output that may contain prose around it. */
|
|
8
|
+
export function parseSummaryJson(raw) {
|
|
9
|
+
const text = String(raw ?? "");
|
|
10
|
+
const start = text.indexOf("[");
|
|
11
|
+
const end = text.lastIndexOf("]");
|
|
12
|
+
if (start === -1 || end === -1 || end <= start) return [];
|
|
13
|
+
let arr;
|
|
14
|
+
try {
|
|
15
|
+
arr = JSON.parse(text.slice(start, end + 1));
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(arr)) return [];
|
|
20
|
+
const VALID = new Set(["preference", "project", "decision", "history"]);
|
|
21
|
+
return arr.filter(
|
|
22
|
+
(item) =>
|
|
23
|
+
item &&
|
|
24
|
+
typeof item === "object" &&
|
|
25
|
+
VALID.has(item.type) &&
|
|
26
|
+
typeof item.title === "string" &&
|
|
27
|
+
item.title.trim() &&
|
|
28
|
+
typeof item.content === "string" &&
|
|
29
|
+
item.content.trim()
|
|
30
|
+
).map((item) => ({
|
|
31
|
+
type: item.type,
|
|
32
|
+
title: item.title.trim(),
|
|
33
|
+
content: item.content.trim(),
|
|
34
|
+
importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
|
|
39
|
+
// block-start {index, blockType}, text-delta {index, text},
|
|
40
|
+
// block-end {index, block}, finish {reason}. Some consumers observe a
|
|
41
|
+
// looser shape ({block} / {delta} / {kind}); normalize before pushing so
|
|
42
|
+
// both real adapter streams and shape-tolerant test doubles assemble.
|
|
43
|
+
const STREAM_CHUNK_TYPES = new Set([
|
|
44
|
+
"block-start",
|
|
45
|
+
"text-delta",
|
|
46
|
+
"reasoning-delta",
|
|
47
|
+
"tool-call-delta",
|
|
48
|
+
"block-end",
|
|
49
|
+
"usage",
|
|
50
|
+
"finish"
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function toProtocolChunk(chunk) {
|
|
54
|
+
switch (chunk.type) {
|
|
55
|
+
case "block-start":
|
|
56
|
+
return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
|
|
57
|
+
case "text-delta":
|
|
58
|
+
return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
|
|
59
|
+
case "reasoning-delta":
|
|
60
|
+
return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
|
|
61
|
+
case "block-end":
|
|
62
|
+
return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
|
|
63
|
+
case "finish":
|
|
64
|
+
return {
|
|
65
|
+
type: "finish",
|
|
66
|
+
reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
|
|
67
|
+
replayState: chunk.replayState
|
|
68
|
+
};
|
|
69
|
+
default:
|
|
70
|
+
return chunk;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only direct human prompts are summarized: plugin-injected context
|
|
75
|
+
// (AGENTS.md, skill bodies, file-change notices) and other machine-originated
|
|
76
|
+
// events must not leak into the memory store. Events without a data payload
|
|
77
|
+
// (minimal test doubles) pass the kind check and are handled by the content
|
|
78
|
+
// check below.
|
|
79
|
+
function collectMessages(session) {
|
|
80
|
+
const messages = [];
|
|
81
|
+
for (const event of session.events ?? []) {
|
|
82
|
+
const kind = event.data?.source?.kind;
|
|
83
|
+
if (event.type !== "user/message") continue;
|
|
84
|
+
if (kind !== undefined && kind !== "user") continue;
|
|
85
|
+
if (!event.data?.content?.length) continue; // nothing to summarize
|
|
86
|
+
messages.push(createUserMessage({ content: event.data.content }));
|
|
87
|
+
}
|
|
88
|
+
return messages.slice(-20);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSummarizer(ctx, service, config) {
|
|
92
|
+
if (!config.autoSummarize) return { dispose: () => {} };
|
|
93
|
+
|
|
94
|
+
const inFlight = new Map();
|
|
95
|
+
let disposed = false;
|
|
96
|
+
|
|
97
|
+
async function summarize(session) {
|
|
98
|
+
if (disposed || inFlight.has(session.id)) return;
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
inFlight.set(session.id, controller);
|
|
101
|
+
try {
|
|
102
|
+
const header = session.requestHeader?.()?.config;
|
|
103
|
+
const route = header?.provider && header?.model
|
|
104
|
+
? { provider: header.provider, model: header.model }
|
|
105
|
+
: undefined;
|
|
106
|
+
if (!route) return;
|
|
107
|
+
const messages = collectMessages(session);
|
|
108
|
+
if (!messages.length) return;
|
|
109
|
+
|
|
110
|
+
const assembler = new BlockAssembler();
|
|
111
|
+
let text = "";
|
|
112
|
+
const options = {
|
|
113
|
+
provider: route.provider,
|
|
114
|
+
model: route.model,
|
|
115
|
+
purpose: "summarization",
|
|
116
|
+
messages: [
|
|
117
|
+
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
118
|
+
...messages
|
|
119
|
+
],
|
|
120
|
+
signal: controller.signal
|
|
121
|
+
};
|
|
122
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
123
|
+
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
|
|
124
|
+
if (chunk.type === "text-delta") {
|
|
125
|
+
text += chunk.text ?? chunk.delta ?? "";
|
|
126
|
+
}
|
|
127
|
+
if (chunk.type === "finish") {
|
|
128
|
+
const reasonKind = chunk.reason?.kind ?? chunk.kind;
|
|
129
|
+
if (reasonKind === "error" || reasonKind === "aborted") return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Direct delta accumulation is the primary extraction path (it works
|
|
133
|
+
// for real protocol chunks {index,text} and looser {delta} shapes
|
|
134
|
+
// alike); the assembler blocks are a fallback for streams that only
|
|
135
|
+
// deliver text inside block-end. This dsh-llm exposes no public
|
|
136
|
+
// no-arg assemble() — blocks() is the message-level API.
|
|
137
|
+
const blocks = assembler.blocks();
|
|
138
|
+
const assembledText = blocks
|
|
139
|
+
.filter((b) => b.type === "text")
|
|
140
|
+
.map((b) => b.text ?? "")
|
|
141
|
+
.join("");
|
|
142
|
+
const entries = parseSummaryJson(text || assembledText);
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
inFlight.delete(session.id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const unsubscribe = ctx.on("session/event", (session, event) => {
|
|
152
|
+
if (disposed || event.type !== "turn/end") return;
|
|
153
|
+
// Return the summarization promise so awaiters observe the writes; the
|
|
154
|
+
// catch keeps listener dispatch from rejecting. Dispose-initiated aborts
|
|
155
|
+
// and external AbortErrors are silent.
|
|
156
|
+
return summarize(session).catch((error) => {
|
|
157
|
+
if (disposed || error?.name === "AbortError") return;
|
|
158
|
+
ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
dispose() {
|
|
164
|
+
if (disposed) return;
|
|
165
|
+
disposed = true;
|
|
166
|
+
unsubscribe?.();
|
|
167
|
+
for (const controller of inFlight.values()) controller.abort();
|
|
168
|
+
inFlight.clear();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
|
+
|
|
3
|
+
const TEXT_OUTPUT = (text) => [{ type: "text", text }];
|
|
4
|
+
|
|
5
|
+
// Wire shape emitted by service.toApiList: shared by memory_search and
|
|
6
|
+
// memory_list so their output schemas always declare every key the runtime
|
|
7
|
+
// value carries (additionalProperties: false would reject undeclared keys).
|
|
8
|
+
const MEMORY_ITEM_SCHEMA = {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: "string", required: true },
|
|
13
|
+
type: { type: "string", required: true },
|
|
14
|
+
title: { type: "string", required: true },
|
|
15
|
+
content: { type: "string", required: true },
|
|
16
|
+
tags: { type: "array", items: { type: "string" } },
|
|
17
|
+
importance: { type: "integer", required: true },
|
|
18
|
+
source: { type: "string" },
|
|
19
|
+
created_at: { type: "string", required: true },
|
|
20
|
+
updated_at: { type: "string", required: true }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createTools(ctx, service, config) {
|
|
25
|
+
const tools = [
|
|
26
|
+
defineTool({
|
|
27
|
+
name: "memory_save",
|
|
28
|
+
description:
|
|
29
|
+
"Persist one memory entry for future sessions (user preferences, project state, decisions). " +
|
|
30
|
+
"Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
|
|
31
|
+
"Merges into an existing entry of the same type when the title matches.",
|
|
32
|
+
parameters: {
|
|
33
|
+
type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
|
|
34
|
+
title: { type: "string", required: true, description: "Short unique title" },
|
|
35
|
+
content: { type: "string", required: true, description: "Memory body" },
|
|
36
|
+
tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
|
|
37
|
+
importance: { type: "integer", description: "1-5; >= threshold auto-injects into future sessions" },
|
|
38
|
+
source: { type: "string", description: "Optional provenance" }
|
|
39
|
+
},
|
|
40
|
+
output: {
|
|
41
|
+
schema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
properties: {
|
|
45
|
+
action: { type: "string", required: true, enum: ["created", "merged"] },
|
|
46
|
+
id: { type: "string", required: true }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
render: (_args, value) => TEXT_OUTPUT(`memory ${value.action}: ${value.id}`)
|
|
50
|
+
},
|
|
51
|
+
async execute(args) {
|
|
52
|
+
const { action, memory } = service.saveWithDedupe({
|
|
53
|
+
type: args.type,
|
|
54
|
+
title: args.title,
|
|
55
|
+
content: args.content,
|
|
56
|
+
tags: args.tags ?? [],
|
|
57
|
+
importance: args.importance ?? 3,
|
|
58
|
+
source: args.source ?? "tool"
|
|
59
|
+
});
|
|
60
|
+
return { action, id: memory.id };
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
defineTool({
|
|
65
|
+
name: "memory_search",
|
|
66
|
+
description: "Full-text search the cross-session memory store. Use when you need past context: how a problem was solved, user preferences, project decisions. Returns matching entries with source and timestamps.",
|
|
67
|
+
parameters: {
|
|
68
|
+
query: { type: "string", required: true, description: "Search text; substring match over title/content/tags" },
|
|
69
|
+
limit: { type: "integer", description: "Max results (default 20)" }
|
|
70
|
+
},
|
|
71
|
+
output: {
|
|
72
|
+
schema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
items: {
|
|
77
|
+
type: "array", required: true,
|
|
78
|
+
items: MEMORY_ITEM_SCHEMA
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
render: (_args, value) => TEXT_OUTPUT(`Found ${value.items.length} memory entr${value.items.length === 1 ? "y" : "ies"}.`)
|
|
83
|
+
},
|
|
84
|
+
async execute(args) {
|
|
85
|
+
const rows = service.toApiList(service.search(args.query, { limit: args.limit ?? 20 }));
|
|
86
|
+
return { items: rows };
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
defineTool({
|
|
91
|
+
name: "memory_list",
|
|
92
|
+
description: "List memory entries by type, high-importance first, then newest, paginated.",
|
|
93
|
+
parameters: {
|
|
94
|
+
type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
|
|
95
|
+
limit: { type: "integer", description: "Page size (default 50)" },
|
|
96
|
+
offset: { type: "integer", description: "Page offset (default 0)" }
|
|
97
|
+
},
|
|
98
|
+
output: {
|
|
99
|
+
schema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
properties: {
|
|
103
|
+
items: {
|
|
104
|
+
type: "array", required: true,
|
|
105
|
+
items: MEMORY_ITEM_SCHEMA
|
|
106
|
+
},
|
|
107
|
+
total: { type: "integer", required: true }
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
render: (_args, value) => TEXT_OUTPUT(`${value.items.length} memory entries (of ${value.total}).`)
|
|
111
|
+
},
|
|
112
|
+
async execute(args) {
|
|
113
|
+
const rows = service.toApiList(service.list({ type: args.type, limit: args.limit ?? 50, offset: args.offset ?? 0 }));
|
|
114
|
+
return { items: rows, total: service.count(args.type) };
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
|
|
118
|
+
defineTool({
|
|
119
|
+
name: "memory_update",
|
|
120
|
+
description: "Modify an existing memory entry (title, content, type, tags, importance).",
|
|
121
|
+
parameters: {
|
|
122
|
+
id: { type: "string", required: true, description: "Memory id" },
|
|
123
|
+
title: { type: "string" },
|
|
124
|
+
content: { type: "string" },
|
|
125
|
+
type: { type: "string", enum: ["preference", "project", "decision", "history"] },
|
|
126
|
+
tags: { type: "array", items: { type: "string" } },
|
|
127
|
+
importance: { type: "integer", description: "1-5" }
|
|
128
|
+
},
|
|
129
|
+
output: {
|
|
130
|
+
schema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
properties: {
|
|
134
|
+
memory: {
|
|
135
|
+
type: "object",
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
properties: {
|
|
138
|
+
id: { type: "string", required: true },
|
|
139
|
+
title: { type: "string", required: true },
|
|
140
|
+
content: { type: "string", required: true }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
|
|
146
|
+
},
|
|
147
|
+
async execute(args) {
|
|
148
|
+
const memory = service.update(args.id, {
|
|
149
|
+
title: args.title,
|
|
150
|
+
content: args.content,
|
|
151
|
+
type: args.type,
|
|
152
|
+
tags: args.tags,
|
|
153
|
+
importance: args.importance
|
|
154
|
+
});
|
|
155
|
+
return { memory: { id: memory.id, title: memory.title, content: memory.content } };
|
|
156
|
+
}
|
|
157
|
+
}),
|
|
158
|
+
|
|
159
|
+
defineTool({
|
|
160
|
+
name: "memory_delete",
|
|
161
|
+
description: "Permanently delete a memory entry.",
|
|
162
|
+
parameters: {
|
|
163
|
+
id: { type: "string", required: true }
|
|
164
|
+
},
|
|
165
|
+
output: {
|
|
166
|
+
schema: {
|
|
167
|
+
type: "object",
|
|
168
|
+
additionalProperties: false,
|
|
169
|
+
properties: { deleted: { type: "boolean", required: true } }
|
|
170
|
+
},
|
|
171
|
+
render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
|
|
172
|
+
},
|
|
173
|
+
async execute(args) {
|
|
174
|
+
const existed = service.getById(args.id) !== undefined;
|
|
175
|
+
if (existed) service.remove(args.id);
|
|
176
|
+
return { deleted: existed };
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
defineTool({
|
|
181
|
+
name: "memory_forget",
|
|
182
|
+
description:
|
|
183
|
+
"Stop a memory from being auto-injected and from appearing in searches and lists without deleting it. " +
|
|
184
|
+
"The entry stays in storage; pass forgotten: false to restore it.",
|
|
185
|
+
parameters: {
|
|
186
|
+
id: { type: "string", required: true },
|
|
187
|
+
forgotten: { type: "boolean", description: "Suppress (true, default) or restore (false) the entry's visibility" }
|
|
188
|
+
},
|
|
189
|
+
output: {
|
|
190
|
+
schema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
properties: {
|
|
194
|
+
memory: {
|
|
195
|
+
type: "object",
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
properties: {
|
|
198
|
+
id: { type: "string", required: true },
|
|
199
|
+
forgotten: { type: "boolean", required: true }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`)
|
|
205
|
+
},
|
|
206
|
+
async execute(args) {
|
|
207
|
+
if (service.getById(args.id) === undefined) {
|
|
208
|
+
throw new Error("memory not found");
|
|
209
|
+
}
|
|
210
|
+
const memory = service.setForget(args.id, args.forgotten ?? true);
|
|
211
|
+
return { memory: { id: memory.id, forgotten: memory.forgotten } };
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const tool of tools) {
|
|
217
|
+
ctx.tools.register(tool);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return tools;
|
|
221
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modusensus/dsh-mneme",
|
|
3
|
+
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite+FTS5 store, Markdown mirrors, 6 model tools, automatic injection, session summarization, and a Web GUI panel",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"default": "./lib/client.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": ["lib", "src"],
|
|
18
|
+
"dsh": {
|
|
19
|
+
"client": {
|
|
20
|
+
"inject": ["slots", "locale", "layout", "connection"],
|
|
21
|
+
"platform": "web"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
26
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
27
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
28
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
|
|
29
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
30
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
31
|
+
}
|
|
32
|
+
}
|