@modusensus/dsh-mneme 0.5.2 → 0.6.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/README.md +434 -419
- package/lib/client.js +1302 -1302
- package/lib/config.js +15 -6
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +96 -4
- package/lib/hot-memory.js +53 -53
- package/lib/index.js +20 -0
- package/lib/reranker.js +218 -218
- package/lib/service.js +1517 -1489
- package/lib/store.js +65 -6
- package/lib/tools.js +16 -5
- package/package.json +1 -1
- package/src/config.js +15 -6
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +96 -4
- package/src/hot-memory.js +53 -53
- package/src/index.js +20 -0
- package/src/reranker.js +218 -218
- package/src/service.js +1517 -1489
- package/src/store.js +65 -6
- package/src/tools.js +16 -5
- package/test/hot-memory.test.js +174 -174
- package/test/normalize-decisions.test.js +120 -0
- package/test/reasoning-effort.test.js +27 -0
- package/test/reranker.test.js +240 -240
- package/test/service-search.test.js +199 -199
- package/test/service.test.js +106 -0
- package/test/store.test.js +76 -0
- package/test/tools.test.js +20 -0
package/lib/store.js
CHANGED
|
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
11
11
|
importance INTEGER NOT NULL DEFAULT 3,
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
session_disposed_at TEXT,
|
|
14
15
|
source TEXT,
|
|
15
16
|
session_id TEXT,
|
|
16
17
|
content_history TEXT,
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
importance: row.importance,
|
|
322
323
|
forgotten: row.forgotten === 1,
|
|
323
324
|
archived: row.archived === 1,
|
|
325
|
+
session_disposed_at: row.session_disposed_at ?? undefined,
|
|
324
326
|
source: row.source ?? undefined,
|
|
325
327
|
session_id: row.session_id ?? undefined,
|
|
326
328
|
content_history: parseJsonArray(row.content_history),
|
|
@@ -567,6 +569,7 @@ export function createStore(path) {
|
|
|
567
569
|
};
|
|
568
570
|
|
|
569
571
|
addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
572
|
+
addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
|
|
570
573
|
addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
571
574
|
addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
572
575
|
addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
@@ -575,6 +578,12 @@ export function createStore(path) {
|
|
|
575
578
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
579
|
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
577
580
|
|
|
581
|
+
// Composite index for session-lifecycle queries (dispose/restore/listBySession).
|
|
582
|
+
// Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
|
|
583
|
+
// ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
|
|
584
|
+
// INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
|
|
585
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
|
|
586
|
+
|
|
578
587
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
579
588
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
580
589
|
addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
@@ -618,7 +627,7 @@ export function createStore(path) {
|
|
|
618
627
|
return ts;
|
|
619
628
|
}
|
|
620
629
|
|
|
621
|
-
function count(type, { includeForgotten = false, includeArchived = false } = {}) {
|
|
630
|
+
function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
622
631
|
const clauses = [];
|
|
623
632
|
const params = [];
|
|
624
633
|
if (type !== undefined) {
|
|
@@ -631,6 +640,9 @@ export function createStore(path) {
|
|
|
631
640
|
if (!includeArchived) {
|
|
632
641
|
clauses.push("archived = 0");
|
|
633
642
|
}
|
|
643
|
+
if (!includeDisposed) {
|
|
644
|
+
clauses.push("session_disposed_at IS NULL");
|
|
645
|
+
}
|
|
634
646
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
635
647
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
636
648
|
}
|
|
@@ -820,6 +832,45 @@ export function createStore(path) {
|
|
|
820
832
|
return getById(id);
|
|
821
833
|
}
|
|
822
834
|
|
|
835
|
+
// --- session lifecycle (v0.6.0) ------------------------------------------
|
|
836
|
+
// Session dispose is orthogonal to `archived`: memory_archive is the user/AI
|
|
837
|
+
// choosing to keep an entry long-term-but-quiet, while session_disposed_at
|
|
838
|
+
// marks entries hidden because the session they were born in was deleted
|
|
839
|
+
// (a reversible "undo" — restoreBySession clears it). They never clobber each
|
|
840
|
+
// other: restoreBySession must not resurrect user-archived memories.
|
|
841
|
+
// Mirrors list/search: disposed rows are hidden by default. A consumer that
|
|
842
|
+
// needs to see the full picture (e.g. a restore flow that tells the user
|
|
843
|
+
// "these N entries were hidden") opts in via includeDisposed.
|
|
844
|
+
function listBySession(sessionId, { includeDisposed = false } = {}) {
|
|
845
|
+
const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
|
|
846
|
+
const rows = db.prepare(
|
|
847
|
+
`SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
|
|
848
|
+
).all(sessionId);
|
|
849
|
+
return rows.map(toRow);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Idempotent by state guard, not timestamp compare (nowIso() differs every
|
|
853
|
+
// call, so a fresh-timestamp re-dispose would spuriously count): dispose only
|
|
854
|
+
// touches rows that are NOT yet disposed; restore only touches rows that ARE.
|
|
855
|
+
// updated_at is deliberately left alone — this is a lifecycle flag, not
|
|
856
|
+
// content — so a true flip is the sole trigger for a mirror generation.
|
|
857
|
+
function setDisposedBySession(sessionId, disposed) {
|
|
858
|
+
const at = disposed ? nowIso() : null;
|
|
859
|
+
let affected = 0;
|
|
860
|
+
runAtomically(() => {
|
|
861
|
+
const result = disposed
|
|
862
|
+
? db.prepare(
|
|
863
|
+
"UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
|
|
864
|
+
).run(at, sessionId)
|
|
865
|
+
: db.prepare(
|
|
866
|
+
"UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
|
|
867
|
+
).run(sessionId);
|
|
868
|
+
affected = result.changes;
|
|
869
|
+
if (affected > 0) incrementGeneration();
|
|
870
|
+
});
|
|
871
|
+
return affected;
|
|
872
|
+
}
|
|
873
|
+
|
|
823
874
|
// --- sleep-mode storage support (v0.4.0) ---------------------------------
|
|
824
875
|
// touchLastAccess stamps the read time on recall/inject paths. It deliberately
|
|
825
876
|
// does NOT bump the mirror generation: reads must not mark the mirror dirty.
|
|
@@ -876,6 +927,7 @@ export function createStore(path) {
|
|
|
876
927
|
const rows = db.prepare(
|
|
877
928
|
`SELECT * FROM memories
|
|
878
929
|
WHERE forgotten = 0 AND archived = 0
|
|
930
|
+
AND session_disposed_at IS NULL
|
|
879
931
|
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
880
932
|
ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
|
|
881
933
|
LIMIT ?`
|
|
@@ -883,7 +935,7 @@ export function createStore(path) {
|
|
|
883
935
|
return rows.map(toRow);
|
|
884
936
|
}
|
|
885
937
|
|
|
886
|
-
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
|
|
938
|
+
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
887
939
|
const clauses = [];
|
|
888
940
|
const params = [];
|
|
889
941
|
if (type) {
|
|
@@ -896,6 +948,9 @@ export function createStore(path) {
|
|
|
896
948
|
if (!includeArchived) {
|
|
897
949
|
clauses.push("archived = 0");
|
|
898
950
|
}
|
|
951
|
+
if (!includeDisposed) {
|
|
952
|
+
clauses.push("session_disposed_at IS NULL");
|
|
953
|
+
}
|
|
899
954
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
900
955
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
901
956
|
const rows = db.prepare(
|
|
@@ -953,7 +1008,7 @@ export function createStore(path) {
|
|
|
953
1008
|
).all(limit);
|
|
954
1009
|
}
|
|
955
1010
|
|
|
956
|
-
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
1011
|
+
function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
|
|
957
1012
|
const q = String(query).trim();
|
|
958
1013
|
if (!q) return [];
|
|
959
1014
|
// Plain LIKE substring scan over title/content/tags (wildcards escaped so
|
|
@@ -962,9 +1017,10 @@ export function createStore(path) {
|
|
|
962
1017
|
const like = `%${escapeLike(q)}%`;
|
|
963
1018
|
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
964
1019
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1020
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
965
1021
|
const rows = db.prepare(
|
|
966
1022
|
`SELECT * FROM memories
|
|
967
|
-
WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
1023
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
968
1024
|
ORDER BY
|
|
969
1025
|
CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
|
|
970
1026
|
importance DESC,
|
|
@@ -995,12 +1051,13 @@ export function createStore(path) {
|
|
|
995
1051
|
* Brute-force cosine similarity over embedded rows. Returns rows decorated
|
|
996
1052
|
* with a `score` (0..1). Only rows with a stored embedding participate.
|
|
997
1053
|
*/
|
|
998
|
-
function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
|
|
1054
|
+
function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
|
|
999
1055
|
if (!Array.isArray(vector) || !vector.length) return [];
|
|
1000
1056
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1057
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
1001
1058
|
const rows = db.prepare(
|
|
1002
1059
|
`SELECT * FROM memories
|
|
1003
|
-
WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1060
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1004
1061
|
).all();
|
|
1005
1062
|
const scored = [];
|
|
1006
1063
|
for (const row of rows) {
|
|
@@ -1871,6 +1928,8 @@ export function createStore(path) {
|
|
|
1871
1928
|
remove,
|
|
1872
1929
|
setForget,
|
|
1873
1930
|
setArchived,
|
|
1931
|
+
listBySession,
|
|
1932
|
+
setDisposedBySession,
|
|
1874
1933
|
touchLastAccess,
|
|
1875
1934
|
demoteToSummary,
|
|
1876
1935
|
restoreContent,
|
package/lib/tools.js
CHANGED
|
@@ -180,9 +180,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
180
180
|
|
|
181
181
|
defineTool({
|
|
182
182
|
name: "memory_delete",
|
|
183
|
-
description: "Permanently delete a memory entry.",
|
|
183
|
+
description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
|
|
184
184
|
parameters: {
|
|
185
|
-
id: { type: "string",
|
|
185
|
+
id: { type: "string", description: "Exact memory id to delete (from memory_list/memory_search output)" },
|
|
186
|
+
query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
|
|
186
187
|
},
|
|
187
188
|
output: {
|
|
188
189
|
schema: {
|
|
@@ -193,9 +194,19 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
193
194
|
render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
|
|
194
195
|
},
|
|
195
196
|
async execute(args) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
if (args.id) {
|
|
198
|
+
const existed = service.getById(args.id) !== undefined;
|
|
199
|
+
if (existed) service.remove(args.id);
|
|
200
|
+
return { deleted: existed };
|
|
201
|
+
}
|
|
202
|
+
if (args.query) {
|
|
203
|
+
const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
|
|
204
|
+
if (best) {
|
|
205
|
+
service.remove(best.id);
|
|
206
|
+
return { deleted: true };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { deleted: false };
|
|
199
210
|
}
|
|
200
211
|
}),
|
|
201
212
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/config.js
CHANGED
|
@@ -4,6 +4,11 @@ export const Config = z.object({
|
|
|
4
4
|
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
5
|
autoInject: z.boolean().default(true),
|
|
6
6
|
autoSummarize: z.boolean().default(true),
|
|
7
|
+
// Session lifecycle (v0.6.0): when enabled, deleting/disposing a session also
|
|
8
|
+
// archives every memory that was born in it (treating the session as a save
|
|
9
|
+
// point — entries stay recoverable via memory_archive/restoreBySession).
|
|
10
|
+
// Default OFF: legacy behavior, a disposed session leaves its memories active.
|
|
11
|
+
sessionLifecycleEnabled: z.boolean().default(false),
|
|
7
12
|
// Optional model override for summarization. When both are non-empty, they
|
|
8
13
|
// take priority over the session's current model. Empty = use the session's
|
|
9
14
|
// active provider/model (same as before).
|
|
@@ -19,11 +24,13 @@ export const Config = z.object({
|
|
|
19
24
|
dreamModel: z.string(),
|
|
20
25
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
21
26
|
// Pass-through reasoning effort for dream's LLM calls. 'none' (default)
|
|
22
|
-
// omits the field so the provider's own default applies;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// empty body ("no json array in llm output")
|
|
27
|
+
// omits the field so the provider's own default applies; 'off' explicitly
|
|
28
|
+
// disables thinking — REQUIRED for thinking-type models (deepseek-v4-flash
|
|
29
|
+
// etc.) that would otherwise drain the whole token budget into reasoning and
|
|
30
|
+
// return an empty body ("no json array in llm output"); low/medium/high are
|
|
31
|
+
// forwarded verbatim to cap reasoning spend.
|
|
26
32
|
dreamReasoningEffort: z.union([
|
|
33
|
+
z.const("off"),
|
|
27
34
|
z.const("low"),
|
|
28
35
|
z.const("medium"),
|
|
29
36
|
z.const("high"),
|
|
@@ -197,9 +204,11 @@ export const Config = z.object({
|
|
|
197
204
|
sleepProvider: z.string().default(""),
|
|
198
205
|
sleepModel: z.string().default(""),
|
|
199
206
|
// Pass-through reasoning effort for sleep's LLM passes, same semantics as
|
|
200
|
-
// dreamReasoningEffort: 'none' (default) omits the field;
|
|
201
|
-
//
|
|
207
|
+
// dreamReasoningEffort: 'none' (default) omits the field; 'off' explicitly
|
|
208
|
+
// disables thinking (thinking-type models would burn the whole budget on
|
|
209
|
+
// reasoning); low/medium/high are forwarded verbatim.
|
|
202
210
|
sleepReasoningEffort: z.union([
|
|
211
|
+
z.const("off"),
|
|
203
212
|
z.const("low"),
|
|
204
213
|
z.const("medium"),
|
|
205
214
|
z.const("high"),
|
package/src/dream/sleep.js
CHANGED
|
@@ -108,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
108
108
|
}
|
|
109
109
|
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
110
110
|
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
111
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
|
|
111
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten && m.type !== "summary");
|
|
112
112
|
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
113
113
|
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
114
114
|
|
|
@@ -239,7 +239,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
239
239
|
const archived = [];
|
|
240
240
|
for (const m of service.all()) {
|
|
241
241
|
if (signal?.aborted) break;
|
|
242
|
-
if (m.archived || m.forgotten) continue;
|
|
242
|
+
if (m.archived || m.forgotten || m.session_disposed_at) continue;
|
|
243
243
|
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
244
244
|
if (!ref) continue;
|
|
245
245
|
const t = new Date(ref).getTime();
|
|
@@ -273,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
273
273
|
const limit = config.sleepPatternMinMemories ?? 100;
|
|
274
274
|
const memories = service
|
|
275
275
|
.list({ limit: 200, includeForgotten: false })
|
|
276
|
-
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
276
|
+
.filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary" && m.type !== "pattern")
|
|
277
277
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
278
278
|
.slice(0, limit);
|
|
279
279
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
@@ -342,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
342
342
|
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
343
343
|
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
344
344
|
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
345
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
345
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten);
|
|
346
346
|
const seen = new Set();
|
|
347
347
|
const related = [];
|
|
348
348
|
const MAX_RELATIONS_PER_ORPHAN = 3;
|
package/src/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions };
|
|
4
|
+
export { validateDecisions, applyDecisions, normalizeDecisions };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -40,6 +40,95 @@ function extractJsonArray(text) {
|
|
|
40
40
|
}
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Field-name drift guard (v0.5.3): thinking-type models (deepseek-v4-flash
|
|
46
|
+
* etc.) occasionally ignore the prompt's exact decision schema and emit alias
|
|
47
|
+
* keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B 输出
|
|
48
|
+
* "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
49
|
+
* 在 validateDecisions 之前把常见变体重写回规范字段名,让"字段名不听话但
|
|
50
|
+
* 语义正确"的输出仍可被应用,而不是整单被拒。覆盖三类漂移:
|
|
51
|
+
* 1. 整个 body 是包在对象里的数组({consolidation:[...]} /
|
|
52
|
+
* {decisions:[...]} / {actions:[...]});
|
|
53
|
+
* 2. 决策对象用了别名键(target_ids→ids、keep_source→keepSource、
|
|
54
|
+
* winner_id→winner、targetIds→ids 等);
|
|
55
|
+
* 3. action 值用了同义词(archived→archive、consolidation→merge 等)。
|
|
56
|
+
* 无归一化必要时原样返回(null→null,调用方"no json array"分支不受影响)。
|
|
57
|
+
*/
|
|
58
|
+
const FIELD_ALIASES = {
|
|
59
|
+
// "consolidation" 也可作 action 键(实测 deepseek-v4-flash 这么写过);
|
|
60
|
+
// 但 normalizeOne 对 action 键做字符串过滤,{consolidation:[...]} 这种
|
|
61
|
+
// wrapper 数组不会被误当成 action(顶层 WRAPPER_KEYS 负责解包)。
|
|
62
|
+
action: ["action", "decision", "operation", "op", "action_type", "actionType", "mode", "consolidation"],
|
|
63
|
+
// 注意:action 别名不含 "type"——create 决策的 type 是合法的记忆类型字段,
|
|
64
|
+
// 不能误当 action。
|
|
65
|
+
ids: ["ids", "target_ids", "targetIds", "targets", "memory_ids", "memoryIds", "id_list", "idList", "memories"],
|
|
66
|
+
reason: ["reason", "rationale", "why", "comment", "note", "explanation"],
|
|
67
|
+
importance: ["importance", "priority", "weight", "level", "score"],
|
|
68
|
+
keepSource: ["keepSource", "keep_source", "source_id", "sourceId", "keeper", "keep_id", "keepId"],
|
|
69
|
+
winner: ["winner", "winner_id", "winnerId", "win_id", "winId", "preferred", "primary"],
|
|
70
|
+
loser: ["loser", "loser_id", "loserId", "lose_id", "loseId", "drop_id", "dropId", "archive_id", "archiveId"],
|
|
71
|
+
title: ["title", "new_title", "newTitle", "merged_title", "mergedTitle", "merge_title", "mergeTitle"],
|
|
72
|
+
content: ["content", "new_content", "newContent", "merged_content", "mergedContent", "merge_content", "mergeContent"],
|
|
73
|
+
evidence: ["evidence", "evidence_ids", "evidenceIds"]
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 保守的 action 值同义词:只收语义无歧义、不可能被误认为记忆类型/其他 action 的映射。
|
|
77
|
+
const ACTION_SYNONYMS = {
|
|
78
|
+
archived: "archive",
|
|
79
|
+
remove: "archive",
|
|
80
|
+
delete: "archive",
|
|
81
|
+
combine: "merge",
|
|
82
|
+
consolidation: "merge",
|
|
83
|
+
consolidate: "merge",
|
|
84
|
+
modify: "update",
|
|
85
|
+
edit: "update"
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const WRAPPER_KEYS = ["consolidation", "decisions", "actions", "results", "updates", "list", "data"];
|
|
89
|
+
|
|
90
|
+
/** 把单个决策对象重写到规范字段名;非对象原样返回。 */
|
|
91
|
+
function normalizeOne(raw) {
|
|
92
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
93
|
+
const d = {};
|
|
94
|
+
const consumed = new Set();
|
|
95
|
+
for (const [canon, aliases] of Object.entries(FIELD_ALIASES)) {
|
|
96
|
+
for (const key of aliases) {
|
|
97
|
+
if (key in raw && raw[key] !== undefined && raw[key] !== null) {
|
|
98
|
+
// action 必须是字符串——{consolidation:[...]} 的 wrapper 数组跳过。
|
|
99
|
+
if (canon === "action" && Array.isArray(raw[key])) continue;
|
|
100
|
+
d[canon] = raw[key];
|
|
101
|
+
consumed.add(key);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 保留未被别名消费的原始键(create 的 type、未来字段)原样透传。
|
|
107
|
+
for (const key of Object.keys(raw)) {
|
|
108
|
+
if (!consumed.has(key)) d[key] = raw[key];
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.action === "string") {
|
|
111
|
+
const low = d.action.toLowerCase();
|
|
112
|
+
if (ACTION_SYNONYMS[low] !== undefined) d.action = ACTION_SYNONYMS[low];
|
|
113
|
+
}
|
|
114
|
+
// ids 若被写成单个字符串则包成数组(validateDecisions 要求数组)。
|
|
115
|
+
if (d.ids !== undefined && typeof d.ids === "string") d.ids = [d.ids];
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 归一化 LLM 决策 body(数组 / 包裹对象 / 单个决策对象),null 原样返回。 */
|
|
120
|
+
function normalizeDecisions(raw) {
|
|
121
|
+
if (!raw) return raw;
|
|
122
|
+
if (Array.isArray(raw)) return raw.map(normalizeOne).filter((d) => d && typeof d === "object");
|
|
123
|
+
if (typeof raw === "object") {
|
|
124
|
+
for (const key of WRAPPER_KEYS) {
|
|
125
|
+
if (Array.isArray(raw[key])) return normalizeDecisions(raw[key]);
|
|
126
|
+
}
|
|
127
|
+
// 单个决策对象 → 包成单元素数组(validateDecisions 要求数组)。
|
|
128
|
+
return [normalizeOne(raw)];
|
|
129
|
+
}
|
|
130
|
+
return raw;
|
|
131
|
+
}
|
|
43
132
|
const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
|
|
44
133
|
|
|
45
134
|
const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
|
|
@@ -406,7 +495,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
406
495
|
let inFlight = null;
|
|
407
496
|
|
|
408
497
|
function shouldTrigger(service) {
|
|
409
|
-
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
498
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary");
|
|
410
499
|
const count = memories.length;
|
|
411
500
|
const chars = totalChars(memories);
|
|
412
501
|
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
@@ -616,7 +705,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
616
705
|
return finish({ ok: false, error: "llm failed", summary: false });
|
|
617
706
|
}
|
|
618
707
|
|
|
619
|
-
|
|
708
|
+
// v0.5.3 字段名归一化兜底:thinking 模型可能输出别名键/wrapper 对象,
|
|
709
|
+
// 在 validateDecisions 之前重写到规范字段名,语义正确但 schema 不听话的
|
|
710
|
+
// 输出不再整单被拒。
|
|
711
|
+
const decisions = normalizeDecisions(extractJsonArray(decisionText));
|
|
620
712
|
if (!Array.isArray(decisions)) {
|
|
621
713
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
|
|
622
714
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
|
@@ -752,7 +844,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
752
844
|
: {}),
|
|
753
845
|
messages: [
|
|
754
846
|
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
755
|
-
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
847
|
+
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
756
848
|
]
|
|
757
849
|
}, reportUsage));
|
|
758
850
|
} catch (error) {
|
package/src/hot-memory.js
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
// Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
|
|
2
|
-
// the latest dialogue rounds, kept strictly apart from the long-term memory
|
|
3
|
-
// store. The injector renders it ahead of the long-term recall block so the
|
|
4
|
-
// agent sees "what we were just talking about" without those rounds ever
|
|
5
|
-
// being persisted as memories. Bounded two ways: maxRounds (count) and
|
|
6
|
-
// maxTokens (budget) — whichever evicts first.
|
|
7
|
-
|
|
8
|
-
// CJK-aware token estimate: one Chinese character ≈ 0.6 tokens (clustering
|
|
9
|
-
// behavior of mainstream tokenizers), one ASCII char ≈ 0.25.
|
|
10
|
-
export function estimateTokens(text) {
|
|
11
|
-
const s = String(text ?? "");
|
|
12
|
-
let cjk = 0;
|
|
13
|
-
for (const ch of s) if (ch >= "\u4e00" && ch <= "\u9fff") cjk++;
|
|
14
|
-
return Math.ceil(cjk * 0.6 + (s.length - cjk) * 0.25);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {{maxRounds?: number, maxTokens?: number}} opts
|
|
19
|
-
* @returns {{add(round: {query: string, response?: string}): void,
|
|
20
|
-
* getContext(): string,
|
|
21
|
-
* rounds(): Array, clear(): void}}
|
|
22
|
-
*/
|
|
23
|
-
export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
|
|
24
|
-
// Entry defense: a non-positive or non-integer maxRounds (0, -1, 1.5, NaN,
|
|
25
|
-
// null, "2") would make the eviction while-loop unbounded — the buffer can
|
|
26
|
-
// never shrink below `buffer.length > maxRounds`, so `add` would spin forever.
|
|
27
|
-
// Fall back to the defaults so a hostile/buggy caller can never wedge the
|
|
28
|
-
// hot-memory buffer in an infinite loop.
|
|
29
|
-
maxRounds = (Number.isInteger(maxRounds) && maxRounds > 0) ? maxRounds : 5;
|
|
30
|
-
maxTokens = (Number.isFinite(maxTokens) && maxTokens > 0) ? maxTokens : 2000;
|
|
31
|
-
const buffer = [];
|
|
32
|
-
|
|
33
|
-
function totalTokens() {
|
|
34
|
-
return buffer.reduce(
|
|
35
|
-
(sum, r) => sum + estimateTokens(`Q: ${r.query}\nA: ${r.response ?? ""}`),
|
|
36
|
-
0
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
add(round) {
|
|
42
|
-
if (!round?.query) return;
|
|
43
|
-
buffer.push({ query: String(round.query), response: String(round.response ?? "") });
|
|
44
|
-
while (buffer.length > maxRounds) buffer.shift();
|
|
45
|
-
while (buffer.length > 1 && totalTokens() > maxTokens) buffer.shift();
|
|
46
|
-
},
|
|
47
|
-
getContext() {
|
|
48
|
-
return buffer.map((r) => `Q: ${r.query}\nA: ${r.response ?? ""}`).join("\n\n");
|
|
49
|
-
},
|
|
50
|
-
rounds: () => [...buffer],
|
|
51
|
-
clear() { buffer.length = 0; }
|
|
52
|
-
};
|
|
53
|
-
}
|
|
1
|
+
// Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
|
|
2
|
+
// the latest dialogue rounds, kept strictly apart from the long-term memory
|
|
3
|
+
// store. The injector renders it ahead of the long-term recall block so the
|
|
4
|
+
// agent sees "what we were just talking about" without those rounds ever
|
|
5
|
+
// being persisted as memories. Bounded two ways: maxRounds (count) and
|
|
6
|
+
// maxTokens (budget) — whichever evicts first.
|
|
7
|
+
|
|
8
|
+
// CJK-aware token estimate: one Chinese character ≈ 0.6 tokens (clustering
|
|
9
|
+
// behavior of mainstream tokenizers), one ASCII char ≈ 0.25.
|
|
10
|
+
export function estimateTokens(text) {
|
|
11
|
+
const s = String(text ?? "");
|
|
12
|
+
let cjk = 0;
|
|
13
|
+
for (const ch of s) if (ch >= "\u4e00" && ch <= "\u9fff") cjk++;
|
|
14
|
+
return Math.ceil(cjk * 0.6 + (s.length - cjk) * 0.25);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{maxRounds?: number, maxTokens?: number}} opts
|
|
19
|
+
* @returns {{add(round: {query: string, response?: string}): void,
|
|
20
|
+
* getContext(): string,
|
|
21
|
+
* rounds(): Array, clear(): void}}
|
|
22
|
+
*/
|
|
23
|
+
export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
|
|
24
|
+
// Entry defense: a non-positive or non-integer maxRounds (0, -1, 1.5, NaN,
|
|
25
|
+
// null, "2") would make the eviction while-loop unbounded — the buffer can
|
|
26
|
+
// never shrink below `buffer.length > maxRounds`, so `add` would spin forever.
|
|
27
|
+
// Fall back to the defaults so a hostile/buggy caller can never wedge the
|
|
28
|
+
// hot-memory buffer in an infinite loop.
|
|
29
|
+
maxRounds = (Number.isInteger(maxRounds) && maxRounds > 0) ? maxRounds : 5;
|
|
30
|
+
maxTokens = (Number.isFinite(maxTokens) && maxTokens > 0) ? maxTokens : 2000;
|
|
31
|
+
const buffer = [];
|
|
32
|
+
|
|
33
|
+
function totalTokens() {
|
|
34
|
+
return buffer.reduce(
|
|
35
|
+
(sum, r) => sum + estimateTokens(`Q: ${r.query}\nA: ${r.response ?? ""}`),
|
|
36
|
+
0
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
add(round) {
|
|
42
|
+
if (!round?.query) return;
|
|
43
|
+
buffer.push({ query: String(round.query), response: String(round.response ?? "") });
|
|
44
|
+
while (buffer.length > maxRounds) buffer.shift();
|
|
45
|
+
while (buffer.length > 1 && totalTokens() > maxTokens) buffer.shift();
|
|
46
|
+
},
|
|
47
|
+
getContext() {
|
|
48
|
+
return buffer.map((r) => `Q: ${r.query}\nA: ${r.response ?? ""}`).join("\n\n");
|
|
49
|
+
},
|
|
50
|
+
rounds: () => [...buffer],
|
|
51
|
+
clear() { buffer.length = 0; }
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/index.js
CHANGED
|
@@ -317,6 +317,26 @@ export const apply = (ctx, config) => {
|
|
|
317
317
|
const summarizer = createSummarizer(ctx, service, cfg);
|
|
318
318
|
disposers.push(summarizer.dispose);
|
|
319
319
|
|
|
320
|
+
// Session lifecycle (v0.6.0): when a session leaves the store and the toggle
|
|
321
|
+
// is enabled, mark every memory born in it as session-disposed (hidden from
|
|
322
|
+
// injection/search/dream but never destroyed — recoverable via
|
|
323
|
+
// restoreBySession). Default off, so a disposed session leaves its memories
|
|
324
|
+
// active (legacy behavior). Every path is guarded: a failure inside the
|
|
325
|
+
// callback must never propagate into DSH's session teardown (that would crash
|
|
326
|
+
// the plugin on the very delete action it serves).
|
|
327
|
+
if (cfg.sessionLifecycleEnabled) {
|
|
328
|
+
disposers.push(ctx.on("session/disposed", (session) => {
|
|
329
|
+
const sessionId = session?.id;
|
|
330
|
+
if (!sessionId) return;
|
|
331
|
+
try {
|
|
332
|
+
const { disposed } = service.disposeBySession(sessionId);
|
|
333
|
+
ctx.logger?.info?.(`[dsh-mneme] session disposed, hid ${disposed} memory(s) for ${sessionId}`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
ctx.logger?.warn?.(`[dsh-mneme] session dispose failed for ${sessionId}: ${String(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
if (ctx.webServer) {
|
|
321
341
|
const api = createApi(ctx, service, settings, commands ?? {
|
|
322
342
|
add: () => { throw new Error("commands unavailable"); },
|