dsh-memu-memory 1.1.3 → 1.2.1
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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
- open_loop:只标真实未完的承诺或已铺垫且影响后续的悬念;普通沉默和未说出口的念头不自动标 true,兑现或明确取消后才关闭。
|
|
31
31
|
- confidence:不确定就低一点。
|
|
32
32
|
- mood_valence/arousal 是感受,confidence 是证据,不把情绪强度当准确性。owner 区分自己的体验、共同经历与对方明确说出的事;不为好召回而夸大 peak。
|
|
33
|
+
- 情绪按事件所在回合还原:emotion 填的是那一瞬间的感受,取那一轮对话里的情绪,不是整理时的心情。同一轮的多条条目各写各的,一场争吵的情绪不串到所有条目。工程事实(owner=about_project)固定中性。dominance 跟着 emotion 一起填:那一刻是自己推动的(正)还是被推着走的(负),没把握就省略,不要用 0 凑数。
|
|
33
34
|
|
|
34
35
|
永久记忆只在对方明确要求、随机侧事件、或极少数「忘了就不再是我」时使用;不要为了显得深刻而 pin。
|
|
35
36
|
不要写全局记忆;全局记忆只由 curator 写。
|
package/lib/prompts.js
CHANGED
|
@@ -19,7 +19,7 @@ export const PROMPT_DEFS = [
|
|
|
19
19
|
key: 'system-guide',
|
|
20
20
|
file: 'system-guide.md',
|
|
21
21
|
title: '记忆使用指南',
|
|
22
|
-
description: '
|
|
22
|
+
description: '以插件消息投放的常驻记忆规则:什么时候检索、什么时候写入、永久记忆与 unpin 的边界;按 domain 选变体,规则变化会在下一回合首步重新投放。',
|
|
23
23
|
variables: ['mustRememberCap', 'unpinBudget', 'shortTermTtlHours', 'anchorTokens', 'turnTokens'],
|
|
24
24
|
scope: 'private',
|
|
25
25
|
domainLayered: true,
|
|
@@ -28,7 +28,7 @@ export const PROMPT_DEFS = [
|
|
|
28
28
|
key: 'private-policy',
|
|
29
29
|
file: 'private-policy.md',
|
|
30
30
|
title: '私有记忆策略',
|
|
31
|
-
description: '
|
|
31
|
+
description: '字段规约模板;当前版本不再自动投放主会话,字段契约由工具 schema 与运行校验提供。保留供查阅和覆盖记录。',
|
|
32
32
|
variables: ['mustRememberCap', 'unpinBudget', 'shortTermTtlHours', 'anchorTokens', 'turnTokens'],
|
|
33
33
|
scope: 'private',
|
|
34
34
|
domainLayered: true,
|
|
@@ -84,8 +84,8 @@ export const CONSOLIDATION_CONTRACTS = {
|
|
|
84
84
|
local: '使用当前可用的记忆工具,先按主题检索去重。兼容细节用 memu_private_update 合并;真正新增或矛盾待复核的内容用 memu_private_remember,一条一件,不自行裁定冲突。不能查询或更新时如实保留限制,不宣称已核对或已修改。没有值得留的就直接结束,不要勉强写。',
|
|
85
85
|
remote: [
|
|
86
86
|
'只输出一个 JSON 对象,不要任何解释或 Markdown 代码块:',
|
|
87
|
-
'{"memories":[{"title":"≤30字","content":"完整正文","layer":"long_term|short_term","tags":["可选"],"open_loop":false,"confidence":0.0,"source":"inferred|user_said|imagined|told","visibility":"
|
|
88
|
-
'你没有记忆工具,也没有输入以外的索引:只返回最多 3 条候选,不声称已检索、合并、更新、裁决或完成写入。不要输出工具调用或 must_remember;矛盾候选在 content 中保留来源、时间与不确定性,交给后续复核。领域表达服从上文,字段服从本 JSON
|
|
87
|
+
'{"memories":[{"title":"≤30字","content":"完整正文","layer":"long_term|short_term","tags":["可选"],"open_loop":false,"confidence":0.0,"source":"inferred|user_said|imagined|told","visibility":"shared|secret","disclosure":"can_tell|only_if_asked|never_say","owner":"me|us|about_user|about_project","emotion":null,"dominance":0,"mood_valence":0.0,"arousal":0.35,"peak":false,"salient_date":null}]}',
|
|
88
|
+
'你没有记忆工具,也没有输入以外的索引:只返回最多 3 条候选,不声称已检索、合并、更新、裁决或完成写入。不要输出工具调用或 must_remember;矛盾候选在 content 中保留来源、时间与不确定性,交给后续复核。领域表达服从上文,字段服从本 JSON 协议。emotion 只接受四轴 {"joy","anger","sorrow","fear"} 的份额对象,没有明确依据就用 null;dominance 是 -1..1 的掌控感。未确定的感受/日期用 null,不为填字段编造。',
|
|
89
89
|
'没有值得留的就输出 {"memories":[]}。',
|
|
90
90
|
].join('\n'),
|
|
91
91
|
}
|
package/lib/remote-summary.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getSummaryConfig } from './memu-config.js'
|
|
2
2
|
import { loadPromptForDomain, CONSOLIDATION_CONTRACTS } from './prompts.js'
|
|
3
3
|
import { getSettings } from './state.js'
|
|
4
|
+
import { normalizeEmotion } from './mood.js'
|
|
5
|
+
import { record } from './activity-log.js'
|
|
4
6
|
|
|
5
7
|
function parseJsonObject(text) {
|
|
6
8
|
const raw = String(text || '').trim()
|
|
@@ -12,31 +14,42 @@ function parseJsonObject(text) {
|
|
|
12
14
|
try { return JSON.parse(raw.slice(start, end + 1)) } catch { return null }
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// 逐条清洗。非法字段不修,只把这一条连同原因丢进 problems:一条坏数据不该让
|
|
18
|
+
// 整批整理白跑,也不该被悄悄改成"看起来合法"的情绪留在库里。
|
|
19
|
+
function cleanMemories(value, problems = []) {
|
|
16
20
|
const list = Array.isArray(value) ? value : []
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
21
|
+
const out = []
|
|
22
|
+
list.forEach((m, index) => {
|
|
23
|
+
try {
|
|
24
|
+
out.push({
|
|
25
|
+
title: String(m?.title || '').trim().slice(0, 120),
|
|
26
|
+
content: String(m?.content || '').trim(),
|
|
27
|
+
tags: Array.isArray(m?.tags) ? m.tags.slice(0, 20).map((t) => String(t)) : [],
|
|
28
|
+
openLoop: m?.open_loop === true || m?.openLoop === true,
|
|
29
|
+
confidence: Number.isFinite(Number(m?.confidence)) ? Number(m.confidence) : 0.8,
|
|
30
|
+
layer: ['long_term', 'short_term'].includes(m?.layer) ? m.layer : 'long_term',
|
|
31
|
+
disclosure: ['can_tell', 'only_if_asked', 'never_say'].includes(m?.disclosure) ? m.disclosure : 'can_tell',
|
|
32
|
+
// A consolidation pass that forgets the field must not invent a scope: the default
|
|
33
|
+
// here has to match the tool path (`privateRemember`, which defaults to 'shared').
|
|
34
|
+
// 'session' used to be the fallback, which silently turned every sloppy summary into
|
|
35
|
+
// a memory that died with its conversation (and the level itself is gone now).
|
|
36
|
+
visibility: ['shared', 'secret'].includes(m?.visibility) ? m.visibility : 'shared',
|
|
37
|
+
owner: ['me', 'us', 'about_user', 'about_project'].includes(m?.owner) ? m.owner : 'me',
|
|
38
|
+
source: ['user_said', 'inferred', 'imagined', 'told'].includes(m?.source) ? m.source : 'inferred',
|
|
39
|
+
// 缺字段是"没读到情绪",null 才是;这里不能拿 valence 反推四轴。
|
|
40
|
+
emotion: normalizeEmotion(m?.emotion ?? null),
|
|
41
|
+
// 缺失或非法给 null 而不是 0:0 表示"明确中性",不是"不知道"。
|
|
42
|
+
dominance: Number.isFinite(m?.dominance) ? Math.max(-1, Math.min(1, m.dominance)) : null,
|
|
43
|
+
moodValence: Number.isFinite(m?.mood_valence) ? Math.max(-1, Math.min(1, m.mood_valence)) : null,
|
|
44
|
+
arousal: Number.isFinite(m?.arousal) ? Math.max(0, Math.min(1, m.arousal)) : null,
|
|
45
|
+
peak: m?.peak === true,
|
|
46
|
+
salientDate: typeof m?.salient_date === 'string' ? m.salient_date : null,
|
|
47
|
+
})
|
|
48
|
+
} catch (error) {
|
|
49
|
+
problems.push({ index, title: String(m?.title || '').slice(0, 120), error: String(error?.message || error) })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
return out.filter((m) => m.content).slice(0, 20)
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
// Remote mode: the configured chat endpoint does the summarizing. The session
|
|
@@ -87,7 +100,20 @@ export async function summarizeRemote({ transcript, libraryName, domain, signal,
|
|
|
87
100
|
const text = data?.choices?.[0]?.message?.content
|
|
88
101
|
const parsed = parseJsonObject(text)
|
|
89
102
|
if (!parsed) throw new Error(`remote summary returned non-JSON: ${String(text || body).slice(0, 600)}`)
|
|
90
|
-
|
|
103
|
+
const problems = []
|
|
104
|
+
const memories = cleanMemories(parsed.memories, problems)
|
|
105
|
+
if (problems.length) {
|
|
106
|
+
// 远程模型的错误只能靠日志查,静默丢弃等于把问题留给下一个人复现。
|
|
107
|
+
record({
|
|
108
|
+
kind: 'consolidate',
|
|
109
|
+
scope: 'private',
|
|
110
|
+
ok: false,
|
|
111
|
+
level: 'warn',
|
|
112
|
+
message: `远程整理有 ${problems.length} 条被拒绝:${problems[0].error}`,
|
|
113
|
+
detail: { rejected: problems },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return memories
|
|
91
117
|
} finally {
|
|
92
118
|
clearTimeout(timer)
|
|
93
119
|
signal?.removeEventListener?.('abort', onAbort)
|
package/lib/rpc.js
CHANGED
|
@@ -18,7 +18,7 @@ import { statsView } from './stats.js'
|
|
|
18
18
|
import { getMemuConfig, saveMemuConfig, setEmbeddingSource, testMemuConfig, getSummaryConfig, getVisionConfig } from './memu-config.js'
|
|
19
19
|
import { getLocalVectorState, downloadLocalVectorModel, installLocalVectorRuntime, selectLocalVectorModel, switchLocalVectorProfile, rollbackLocalVectorProfile, abortLocalVectorMigration, testLocalVector } from './local-vector-service.js'
|
|
20
20
|
import { webUiHtml, WEBUI_PATH, standaloneHtml, standaloneAsset, SETTINGS_PATH } from './webui.js'
|
|
21
|
-
import { getGraph, listEntities, createEntity, renameEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
|
|
21
|
+
import { getGraph, listEntities, createEntity, renameEntity, deleteEntity, linkEntries, unlinkEntry, getRelationship, updateRelationship, graphExport, restoreGraphData } from './graph.js'
|
|
22
22
|
import { getAttachment, attachmentThumbnail, attachmentPreview, exportMediaBundle, importMediaBundle, streamMediaExport, importMediaStream } from './media.js'
|
|
23
23
|
import { listEvents, logStats, clearEvents } from './activity-log.js'
|
|
24
24
|
|
|
@@ -86,6 +86,11 @@ async function privateListView(scope) {
|
|
|
86
86
|
decay: Math.min(1, Math.max(0, decayStrength(e) / 1.5)),
|
|
87
87
|
moodValence: e?.moodValence ?? null,
|
|
88
88
|
arousal: e?.arousal ?? null,
|
|
89
|
+
// 构成与掌控感分开投影:同一个 valence 说得出"多难过",说不出"是怒还是怕"。
|
|
90
|
+
emotion: e?.emotion ?? null,
|
|
91
|
+
dominance: e?.dominance ?? null,
|
|
92
|
+
// 来源是审计字段,只读:面板显示"这个情绪是谁给的",不给改。
|
|
93
|
+
emotionSource: e?.emotionSource ?? null,
|
|
89
94
|
peak: e?.peak === true,
|
|
90
95
|
salientDate: e?.salientDate || null,
|
|
91
96
|
attachments: attachmentPreview(libraryId, e || {}),
|
|
@@ -250,13 +255,44 @@ async function readSessionTitles(sessionQuery, ids) {
|
|
|
250
255
|
for (const r of await sessionQuery.readTitleSnapshots(ids) || []) {
|
|
251
256
|
if (r?.status !== 'fulfilled' || !r.sessionId) continue
|
|
252
257
|
const title = r.value?.title?.title || r.value?.session?.title
|
|
253
|
-
if (title) titles.set(r.sessionId, title)
|
|
258
|
+
if (title && String(title).trim()) titles.set(r.sessionId, String(title).trim())
|
|
254
259
|
}
|
|
255
260
|
}
|
|
256
261
|
} catch {}
|
|
257
262
|
return titles
|
|
258
263
|
}
|
|
259
264
|
|
|
265
|
+
// 日志行原本只有 sessionId/libraryId 裸 id:日志页和侧边栏提醒要回答"这是哪个对话、
|
|
266
|
+
// 哪个文件夹",得在服务端把标题与库名拼上。标题走宿主 readTitleSnapshots(批量一次),
|
|
267
|
+
// 查不到的行保留 null,客户端回退到短 id。
|
|
268
|
+
async function decorateLogRows(ctx, rows) {
|
|
269
|
+
const ids = [...new Set(rows.map(row => row?.sessionId).filter(Boolean))]
|
|
270
|
+
const titles = await readSessionTitles(ctx?.get?.('sessionQuery'), ids)
|
|
271
|
+
return rows.map(row => ({
|
|
272
|
+
...row,
|
|
273
|
+
sessionTitle: row?.sessionId ? (titles.get(row.sessionId) || null) : null,
|
|
274
|
+
libraryName: row?.libraryId ? (getLibrary(row.libraryId)?.name || null) : null,
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 侧边栏"整理提醒"的数据源:跨会话(不按当前对话过滤),失败/成功两列分开,
|
|
279
|
+
// 每列只取最新 limit 条。失败的行带会话标题与库名,客户端用它挂跳转按钮。
|
|
280
|
+
async function consolidationFeed(ctx, payload = {}) {
|
|
281
|
+
const perColumn = Math.max(1, Math.min(20, Number(payload?.limit) || 6))
|
|
282
|
+
const rows = await decorateLogRows(ctx, listEvents({ kind: 'consolidate', limit: 80 }))
|
|
283
|
+
const slim = ({ detail, ...row }) => ({
|
|
284
|
+
...row,
|
|
285
|
+
reasonCode: detail?.reasonCode || detail?.code || null,
|
|
286
|
+
timedOut: detail?.timedOut === true,
|
|
287
|
+
count: detail?.count ?? null,
|
|
288
|
+
force: detail?.force === true,
|
|
289
|
+
})
|
|
290
|
+
return {
|
|
291
|
+
failures: rows.filter(row => row.ok === false).slice(0, perColumn).map(slim),
|
|
292
|
+
successes: rows.filter(row => row.ok !== false).slice(0, perColumn).map(slim),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
260
296
|
// The host's session corpus is the authority for "this conversation still
|
|
261
297
|
// exists". A binding in state.json is deliberately kept after a conversation is
|
|
262
298
|
// deleted so its memory library survives; it must not keep the dead row visible.
|
|
@@ -453,6 +489,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
453
489
|
} catch {}
|
|
454
490
|
const rows = Object.entries(state.sessions || {})
|
|
455
491
|
.map(([sessionId, binding]) => {
|
|
492
|
+
if (binding.consolidationFork) return null
|
|
456
493
|
const inFolder = binding.scopeKey === scopeKey
|
|
457
494
|
|| (binding.libraryId && state.libraries?.[binding.libraryId]?.scopeKey === scopeKey)
|
|
458
495
|
if (!inFolder) return null
|
|
@@ -771,7 +808,9 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
771
808
|
// `source` is writable: correcting provenance is the point of showing it.
|
|
772
809
|
// `salientDate` is writable for the same reason: an entry whose year was
|
|
773
810
|
// dropped has to be repairable from the browser, not only from JSON.
|
|
774
|
-
|
|
811
|
+
// `emotion`/`dominance` 可写:面板修的是那条记忆所述事件的感受。
|
|
812
|
+
// `emotionSource` 故意不在名单里:来源是审计结论,能改就等于能伪造。
|
|
813
|
+
const allowed = ['content', 'title', 'tags', 'status', 'openLoop', 'disclosure', 'confidence', 'owner', 'visibility', 'layer', 'source', 'salientDate', 'track', 'peak', 'moodValence', 'arousal', 'emotion', 'dominance', 'attachments', 'context']
|
|
775
814
|
// An unknown key used to be dropped in silence, so a typo here or in a script
|
|
776
815
|
// came back as a successful save that changed nothing. Refuse instead: the
|
|
777
816
|
// caller can only fix a field name it is told is wrong.
|
|
@@ -874,6 +913,8 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
874
913
|
return createEntity(payload || {})
|
|
875
914
|
case 'renameEntity':
|
|
876
915
|
return renameEntity(payload || {})
|
|
916
|
+
case 'deleteEntity':
|
|
917
|
+
return deleteEntity(payload || {})
|
|
877
918
|
case 'linkEntries':
|
|
878
919
|
return linkEntries(payload || {})
|
|
879
920
|
case 'unlinkEntry':
|
|
@@ -1132,7 +1173,7 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1132
1173
|
return engineView()
|
|
1133
1174
|
case 'getActivityLog':
|
|
1134
1175
|
return {
|
|
1135
|
-
events: listEvents({
|
|
1176
|
+
events: await decorateLogRows(ctx, listEvents({
|
|
1136
1177
|
limit: payload?.limit,
|
|
1137
1178
|
kind: payload?.kind,
|
|
1138
1179
|
level: payload?.level,
|
|
@@ -1140,9 +1181,17 @@ async function dispatch(ctx, endpoint, payload = {}) {
|
|
|
1140
1181
|
sessionId: payload?.sessionId,
|
|
1141
1182
|
since: payload?.since,
|
|
1142
1183
|
q: payload?.q,
|
|
1143
|
-
}),
|
|
1184
|
+
})),
|
|
1144
1185
|
stats: logStats(),
|
|
1145
1186
|
}
|
|
1187
|
+
case 'getConsolidationFeed':
|
|
1188
|
+
return await consolidationFeed(ctx, payload)
|
|
1189
|
+
case 'clearConsolidationFailures': {
|
|
1190
|
+
// 侧边栏失败提醒的"清除":只删 consolidate 且 ok=false 的行。返回删除条数和
|
|
1191
|
+
// 清除后的新 feed,客户端直接回填,不用二次拉取。
|
|
1192
|
+
const cleared = clearEvents({ kind: 'consolidate', ok: false })
|
|
1193
|
+
return { cleared: cleared.removed, feed: await consolidationFeed(ctx, payload) }
|
|
1194
|
+
}
|
|
1146
1195
|
case 'clearActivityLog':
|
|
1147
1196
|
return clearEvents()
|
|
1148
1197
|
default:
|
package/lib/state.js
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
pruneBackups, restoreFromBackup, withBackupLock,
|
|
11
11
|
} from './backup.js'
|
|
12
12
|
import { migrateIndexVisibility } from './visibility.js'
|
|
13
|
-
import { memuDbFor, memuGlobalDbFor } from './vector-profile.js'
|
|
13
|
+
import { memuDbFor, memuGlobalDbFor, removeActiveLibrary } from './vector-profile.js'
|
|
14
|
+
import { normalizeEmotion, derivedValence } from './mood.js'
|
|
14
15
|
|
|
15
16
|
const STATE_FILE = () => path.join(dataDir(), 'state.json')
|
|
16
17
|
const GLOBAL_DB = () => path.join(dataDir(), 'global.sqlite3')
|
|
@@ -94,6 +95,12 @@ export const DEFAULT_SETTINGS = {
|
|
|
94
95
|
// "gone", and short enough that a conversation which ended gets tidied in the
|
|
95
96
|
// same sitting instead of waiting for a quiet hour that may never arrive.
|
|
96
97
|
sleepAfterIdleMinutes: 3,
|
|
98
|
+
// 主动记忆:分数门槛、饥饿阈值(空闲超过它无条件检查)、两种抑制冷却(分钟;整理冷却默认 0,
|
|
99
|
+
// 薄证据的重复 fork 由评分拦截,只有用户显式配置才启用硬冷却)。
|
|
100
|
+
activeMemoryMinScore: 0.8,
|
|
101
|
+
activeMemoryStarvationMinutes: 15,
|
|
102
|
+
activeMemoryCooldownMinutes: 0,
|
|
103
|
+
activeMemoryRecallCooldownMinutes: 15,
|
|
97
104
|
autoCurateEveryHours: 0,
|
|
98
105
|
autoCurateSessionLimit: 8,
|
|
99
106
|
// 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
|
|
@@ -343,6 +350,8 @@ export function updateSettings(patch) {
|
|
|
343
350
|
mustRememberCap: [1, 100], randomCueChance: [0, 1], shortTermTtlHours: [0, 8760],
|
|
344
351
|
unpinBudgetPerSession: [1, 20], unpinCooldownMinutes: [0, 1440], sleepAfterIdleMinutes: [1, 1440],
|
|
345
352
|
autoCurateEveryHours: [0, 8760], autoCurateSessionLimit: [1, 10], purgeArchivedAfterDays: [0, 36500],
|
|
353
|
+
activeMemoryMinScore: [0, 10], activeMemoryStarvationMinutes: [1, 1440],
|
|
354
|
+
activeMemoryCooldownMinutes: [0, 10080], activeMemoryRecallCooldownMinutes: [0, 10080],
|
|
346
355
|
}
|
|
347
356
|
const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
|
|
348
357
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
@@ -541,13 +550,20 @@ export function deleteLibrary(id) {
|
|
|
541
550
|
const state = loadState()
|
|
542
551
|
if (!state.libraries[id]) return { ok: false, error: 'library not found' }
|
|
543
552
|
if (state.libraries[id].archived !== true) return { ok: false, error: 'archive the library before deleting it' }
|
|
553
|
+
const activeVectorDb = removeActiveLibrary(id)
|
|
554
|
+
const legacyVectorDb = path.join(librariesDir(), id + '.sqlite3')
|
|
544
555
|
delete state.libraries[id]
|
|
545
556
|
for (const [sessionId, binding] of Object.entries(state.sessions || {})) {
|
|
546
557
|
if (binding?.libraryId === id) delete state.sessions[sessionId]
|
|
547
558
|
}
|
|
548
559
|
saveState(state)
|
|
549
560
|
const removed = []
|
|
550
|
-
|
|
561
|
+
const vectorPrefix = id + '.'
|
|
562
|
+
const vectorGenerationFiles = fs.existsSync(librariesDir())
|
|
563
|
+
? fs.readdirSync(librariesDir()).filter(name => name.startsWith(vectorPrefix) && name.endsWith('.sqlite3')).map(name => path.join(librariesDir(), name))
|
|
564
|
+
: []
|
|
565
|
+
for (const file of [activeVectorDb, legacyVectorDb, ...vectorGenerationFiles, ...['json', 'entities.json', 'media.json', 'stats.json'].map(suffix => path.join(indexDir(), `${id}.${suffix}`))]) {
|
|
566
|
+
if (!file) continue
|
|
551
567
|
try {
|
|
552
568
|
if (fs.existsSync(file)) { fs.rmSync(file, { force: true }); removed.push(file) }
|
|
553
569
|
} catch {}
|
|
@@ -791,6 +807,8 @@ export function markInjected(sessionId, ids, turn) {
|
|
|
791
807
|
for (const id of ids || []) at[id] = turn ?? Date.now()
|
|
792
808
|
binding.injectedAt = at
|
|
793
809
|
binding.lastInjectedTurn = turn ?? binding.lastInjectedTurn ?? 0
|
|
810
|
+
// 主动记忆抑制要一个"最近一次真正注入了记忆"的墙钟时间;turn 号不能跨会话比较。
|
|
811
|
+
binding.lastInjectedAt = stamp()
|
|
794
812
|
return state
|
|
795
813
|
})
|
|
796
814
|
}
|
|
@@ -870,17 +888,16 @@ export function consumeCue(sessionId, cueId) {
|
|
|
870
888
|
return true
|
|
871
889
|
}
|
|
872
890
|
|
|
873
|
-
// The session's running mood
|
|
874
|
-
//
|
|
875
|
-
// as a stored memory.
|
|
891
|
+
// The session's running mood is a retrieval lens on the binding, not a fact about
|
|
892
|
+
// the user: it must never be recalled as a stored memory.
|
|
876
893
|
export function setSessionMood(sessionId, mood) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
})
|
|
894
|
+
// 给了 emotion 就以四轴为准,valence 只是投影;只有 valence 的旧形状原样存,不反推四轴。
|
|
895
|
+
const emotion = normalizeEmotion(mood?.emotion ?? null)
|
|
896
|
+
const bounded = (raw, min, max) => Math.max(min, Math.min(max, Number.isFinite(raw) ? raw : 0)) // 显式 0 是读数的一部分,不能用 `|| 默认值`
|
|
897
|
+
return setSessionBinding(sessionId, { mood: {
|
|
898
|
+
valence: emotion ? derivedValence(emotion) : bounded(mood?.valence, -1, 1),
|
|
899
|
+
emotion, arousal: bounded(mood?.arousal, 0, 1), dominance: bounded(mood?.dominance, -1, 1), at: Date.now(),
|
|
900
|
+
} })
|
|
884
901
|
}
|
|
885
902
|
|
|
886
903
|
export function getSessionMood(sessionId) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// One ruler for "do these two memories say the same thing".
|
|
2
|
+
//
|
|
3
|
+
// 5: the recall side (candidate folding, MMR hard skip) and the
|
|
4
|
+
// write side (the duplicate gate) must measure with the same formula, or recall
|
|
5
|
+
// folds what write kept and vice versa. Everything here is deliberately
|
|
6
|
+
// dependency-free — two bigram sets and the Dice coefficient — and the text
|
|
7
|
+
// preprocessing is the one the contradiction check always used (lowercase, strip
|
|
8
|
+
// whitespace/punctuation/symbols), so findConflict's behaviour is unchanged by
|
|
9
|
+
// this extraction.
|
|
10
|
+
|
|
11
|
+
// Same fact: a recall cluster keeps one candidate, MMR hard-skips the rest.
|
|
12
|
+
export const DUP_DICE = 0.82
|
|
13
|
+
// Write-side reject: a new memory this close to an existing one is refused.
|
|
14
|
+
export const DUP_REJECT_DICE = 0.90
|
|
15
|
+
// Same topic, different fact: soft-penalty band on recall, duplicateOf tag on write.
|
|
16
|
+
export const TOPIC_DICE = 0.55
|
|
17
|
+
// Write-side pruning: skip the bigram pass when two texts differ in length by more
|
|
18
|
+
// than this fraction of the longer one. At this ratio Dice cannot reach the reject
|
|
19
|
+
// band even with zero unique bigrams on the short side.
|
|
20
|
+
export const LENGTH_PRUNE_RATIO = 0.5
|
|
21
|
+
|
|
22
|
+
// Length-disparate pairs (gap > LENGTH_PRUNE_RATIO) do not go through Dice — the
|
|
23
|
+
// ratio math makes a reject-band Dice impossible, which is exactly the hole a
|
|
24
|
+
// "rewrite it much longer" evader walks through. Two cheap containment probes
|
|
25
|
+
// close the hole without paying the full bigram pass:
|
|
26
|
+
// * verbatim containment — the short cleaned text is a substring of the long one;
|
|
27
|
+
// * core coverage — every character bigram of the short text occurs somewhere in
|
|
28
|
+
// the long one ("核心词全含"), probed with String.includes so the long side is
|
|
29
|
+
// never materialised into a bigram set and a miss fails fast on the first
|
|
30
|
+
// absent gram.
|
|
31
|
+
// Both are strictly stronger signals than Dice ≥ DUP_REJECT_DICE (a verbatim or
|
|
32
|
+
// all-grams-contained overlap cannot be an accident), so they report a floor in
|
|
33
|
+
// the reject band instead of a made-up Dice value. Partial coverage deliberately
|
|
34
|
+
// reports 0: with a >0.5 length gap there is no honest way to say "same topic",
|
|
35
|
+
// and a false reject on the write side is worse than a rare paraphrase bypass —
|
|
36
|
+
// the residual risk (heavily reworded padding) is documented in
|
|
37
|
+
export const CONTAINMENT_FLOOR = 0.92
|
|
38
|
+
export const CORE_COVER_FLOOR = DUP_REJECT_DICE
|
|
39
|
+
// Below this many cleaned characters the probe has too little signal (a 4-char
|
|
40
|
+
// string and its bigrams occur in unrelated texts all the time); above the max the
|
|
41
|
+
// probe budget is exceeded and both sides are substantial enough to trust the
|
|
42
|
+
// length prune again. Bounds keep the write-side gate O(|short|·|long|) native
|
|
43
|
+
// scans per pruned pair instead of O(N²) bigram-set construction.
|
|
44
|
+
const PROBE_MIN_CHARS = 6
|
|
45
|
+
const PROBE_MAX_CHARS = 240
|
|
46
|
+
|
|
47
|
+
/** The one text preprocessing every measurement here shares. */
|
|
48
|
+
export function clean(text) {
|
|
49
|
+
return String(text || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Lowercase, strip whitespace/punctuation/symbols, then collect character bigrams. */
|
|
53
|
+
export function bigrams(text) {
|
|
54
|
+
const c = clean(text)
|
|
55
|
+
const out = new Set()
|
|
56
|
+
for (let i = 0; i < c.length - 1; i += 1) out.add(c.slice(i, i + 2))
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Dice coefficient over two bigram sets; 0 when either side is empty. */
|
|
61
|
+
export function dice(a, b) {
|
|
62
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
63
|
+
let shared = 0
|
|
64
|
+
for (const gram of a) if (b.has(gram)) shared += 1
|
|
65
|
+
return (2 * shared) / (a.size + b.size)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** |len(A)-len(B)| / max(len); 0 when both sides are empty. */
|
|
69
|
+
export function lengthGap(aText, bText) {
|
|
70
|
+
const a = String(aText || '').length
|
|
71
|
+
const b = String(bText || '').length
|
|
72
|
+
return Math.abs(a - b) / Math.max(a, b, 1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Dice over raw texts with the write-side length pruning applied: pairs whose
|
|
77
|
+
* lengths differ too much return 0 without paying for the bigram build (the
|
|
78
|
+
* gate runs over the whole library on every write). 0 can never win a
|
|
79
|
+
* max-similarity scan, so pruned pairs simply never become the best match —
|
|
80
|
+
* unless they hit one of the two containment probes below, which are the only
|
|
81
|
+
* way a length-disparate pair reaches the reject band.
|
|
82
|
+
*/
|
|
83
|
+
export function similarTexts(aText, bText) {
|
|
84
|
+
const a = String(aText || '')
|
|
85
|
+
const b = String(bText || '')
|
|
86
|
+
if (lengthGap(a, b) > LENGTH_PRUNE_RATIO) {
|
|
87
|
+
const aClean = clean(a)
|
|
88
|
+
const bClean = clean(b)
|
|
89
|
+
if (!aClean || !bClean) return 0
|
|
90
|
+
const shortClean = aClean.length <= bClean.length ? aClean : bClean
|
|
91
|
+
const longClean = aClean.length <= bClean.length ? bClean : aClean
|
|
92
|
+
if (shortClean.length < PROBE_MIN_CHARS || shortClean.length > PROBE_MAX_CHARS) return 0
|
|
93
|
+
// Probe 1: verbatim containment of the short text inside the long one.
|
|
94
|
+
if (longClean.includes(shortClean)) return CONTAINMENT_FLOOR
|
|
95
|
+
// Probe 2: core coverage — every bigram of the short text occurs in the long
|
|
96
|
+
// one. includes() over the cleaned long text is exactly long-bigram-set
|
|
97
|
+
// membership (a hit means the two characters are adjacent somewhere in the
|
|
98
|
+
// long text), so no long-side set is ever built; the first absent gram ends
|
|
99
|
+
// the probe.
|
|
100
|
+
for (let i = 0; i < shortClean.length - 1; i += 1) {
|
|
101
|
+
if (!longClean.includes(shortClean.slice(i, i + 2))) return 0
|
|
102
|
+
}
|
|
103
|
+
return CORE_COVER_FLOOR
|
|
104
|
+
}
|
|
105
|
+
return dice(bigrams(a), bigrams(b))
|
|
106
|
+
}
|