dsh-cerebrum 0.1.3 → 0.1.4
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 +11 -2
- package/docs/publish.md +6 -1
- package/lib/api.js +19 -2
- package/lib/client.js +24 -4
- package/lib/core/affect.js +4 -2
- package/lib/core/judge.js +9 -6
- package/lib/core/math.js +5 -1
- package/lib/core/memory.js +109 -19
- package/lib/service.js +70 -7
- package/lib/tools.js +11 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -56,7 +56,8 @@ dsh plugin --profile desktop add D:\workspace\dsh-cerebrum
|
|
|
56
56
|
装好插件后,输入框上方会出现 🧠 芯片:显示当前情绪、记忆量、好感度级别;点击展开**大脑仪表盘**(效价/唤醒/支配条、DA/5-HT/NE/ACh、五因素、记忆/技能/意图/好感)。
|
|
57
57
|
|
|
58
58
|
- **不喜欢可以关掉**:面板里的 🙈 隐藏芯片(点小 🧠 恢复);彻底关闭用配置 `ui.enabled: false`(重启宿主生效,芯片完全不出现);
|
|
59
|
-
-
|
|
59
|
+
- 面板可**拖动**(按住标题栏,位置会记住)、可 **📌 固定**到输入栏上方成横条;
|
|
60
|
+
- **安全**:回环 API 只接受本机 Host/Origin,不做通配 CORS;可选 `api.token`(同时设置浏览器 `localStorage['cerebrum.token']` 即可让面板带上它);`/recall` 只读、不训练记忆;`/interact` 限流 30 次/分;
|
|
60
61
|
- **宠物联动(免配置)**:检测到同源的 `dsh-pet`(`/dsh-pet-7340`)时,情绪变化会自动推给桌宠——加锁 → 让宠物说一句大脑台词 → 释放锁;不需要用户配置。不想要联动可设 `ui.drivePet: false`;
|
|
61
62
|
- 数据来自宿主内置的 127.0.0.1 只读 API(`http://127.0.0.1:18734`,端口可配 `config.api.port`);
|
|
62
63
|
- UI 只读:修改记忆/情绪仍通过 `cerebrum_*` 工具;
|
|
@@ -98,7 +99,7 @@ dsh plugin --profile desktop add D:\workspace\dsh-cerebrum
|
|
|
98
99
|
## 开发
|
|
99
100
|
|
|
100
101
|
```powershell
|
|
101
|
-
npm test #
|
|
102
|
+
npm test # 79 个单元 + 集成测试(node:test)
|
|
102
103
|
npm run check # 用安装版 DSH 内的真实 Cordis 容器加载插件并跑一轮对话
|
|
103
104
|
npm run demo # 离线仿真,打印情绪/调质时间线并写 examples/.demo/timeline.json
|
|
104
105
|
npm run dashboard # 生成 examples/.demo/dashboard.html(自包含可视化)
|
|
@@ -111,6 +112,14 @@ npm run retention # 计算幂律遗忘曲线与间隔效应,写 examples/.demo
|
|
|
111
112
|
|
|
112
113
|

|
|
113
114
|
|
|
115
|
+
## 已知限制
|
|
116
|
+
|
|
117
|
+
- **性能**:检索为纯 JS(SDR + BM25),几万条情景时单次召回仍在毫秒级,但超大库(10 万+)会变慢;
|
|
118
|
+
- **GUI 端口**:面板按 18734–18743 探测宿主 API;若把 `api.port` 改到该段之外,面板会显示"未连接";
|
|
119
|
+
- **token 模式**:开启 `api.token` 后,脚本请求必须带 `x-cerebrum-token`;面板需在浏览器 `localStorage['cerebrum.token']` 设置同一个值;
|
|
120
|
+
- **多实例并发**:同一工作区被两个 DSH 实例(如 web + desktop)同时写入时没有跨进程锁,内存各持一份、后写者覆盖;建议同一工作区只跑一个实例;
|
|
121
|
+
- **无 embedding**:语义检索靠 SDR + 词面,极少数近义改写会走"知道感"(probe)而不是直接召回。
|
|
122
|
+
|
|
114
123
|
## 文档
|
|
115
124
|
|
|
116
125
|
- [设计文档(中文)](docs/DESIGN.zh.md) — 脑结构映射、公式、参数、集成方式、评估方案
|
package/docs/publish.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# 发布 dsh-cerebrum 到 DSH 插件市场
|
|
2
2
|
|
|
3
|
+
> **发布纪律**
|
|
4
|
+
> - **发版必须经用户明确同意**:改代码只提交 main,不自动发版;确认后才运行 `node scripts/release.mjs --yes`
|
|
5
|
+
> - 小改动攒成一批再发,不为了一个修复单独发一个版本
|
|
6
|
+
> - 发版后等 npm registry 传播(可能几分钟),必要时网页批准 staged 版本
|
|
7
|
+
|
|
3
8
|
> **发布状态(2026-10-28)**
|
|
4
9
|
> - GitHub 公开仓库:https://github.com/liu-wen-wu/dsh-cerebrum(main + tag v0.1.1,SSH 推送)
|
|
5
|
-
> - npm:`dsh-cerebrum@0.1.
|
|
10
|
+
> - npm:`dsh-cerebrum@0.1.3` 已上线(latest;含 #121 复审要求的安全加固)
|
|
6
11
|
> - 市场收录申请:https://github.com/polang233/dsh-plugin-index/issues/121
|
|
7
12
|
> - 本地安装:`dsh-cerebrum@link:D:/workspace/dsh-cerebrum`(desktop profile,已热加载验证)
|
|
8
13
|
|
package/lib/api.js
CHANGED
|
@@ -51,12 +51,28 @@ const send = (res, status, payload, origin) => {
|
|
|
51
51
|
export const startBrainApi = (service, { port = 18734, ui = { enabled: true }, token = '', logger = console } = {}) => {
|
|
52
52
|
// Interaction rate limit: /interact is the only mutation; cap it to stop flooding.
|
|
53
53
|
let interactWindow = { at: 0, count: 0 }
|
|
54
|
+
// Global request budget: a runaway page/poll can never hammer the brain.
|
|
55
|
+
let requestWindow = { at: 0, count: 0 }
|
|
56
|
+
const withinBudget = (limit = 240) => {
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
if (now - requestWindow.at > 60_000) requestWindow = { at: now, count: 0 }
|
|
59
|
+
requestWindow.count += 1
|
|
60
|
+
return requestWindow.count <= limit
|
|
61
|
+
}
|
|
54
62
|
const server = http.createServer((req, res) => {
|
|
55
63
|
const origin = req.headers.origin
|
|
56
64
|
const allowedOrigin = origin && LOOPBACK_ORIGIN.test(String(origin)) ? String(origin) : null
|
|
57
65
|
const access = checkAccess(req, token)
|
|
58
66
|
if (!access.ok) return send(res, access.status, { error: access.reason }, allowedOrigin)
|
|
59
|
-
if (
|
|
67
|
+
if (!withinBudget()) return send(res, 429, { error: 'rate-limited' }, allowedOrigin)
|
|
68
|
+
if (req.method === 'OPTIONS') {
|
|
69
|
+
if (allowedOrigin) res.setHeader('Access-Control-Allow-Origin', allowedOrigin)
|
|
70
|
+
res.setHeader('Access-Control-Allow-Methods', CORS_METHODS)
|
|
71
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-cerebrum-token')
|
|
72
|
+
res.setHeader('Vary', 'Origin')
|
|
73
|
+
res.statusCode = 204
|
|
74
|
+
return res.end()
|
|
75
|
+
}
|
|
60
76
|
if (req.method !== 'GET') return send(res, 405, { error: 'method not allowed' }, allowedOrigin)
|
|
61
77
|
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
|
62
78
|
const reply = (status, payload) => send(res, status, payload, allowedOrigin)
|
|
@@ -74,7 +90,8 @@ export const startBrainApi = (service, { port = 18734, ui = { enabled: true }, t
|
|
|
74
90
|
case '/recall': {
|
|
75
91
|
const query = url.searchParams.get('q') ?? ''
|
|
76
92
|
const sessionId = url.searchParams.get('session') || undefined
|
|
77
|
-
|
|
93
|
+
// A read endpoint must not rehearse memory (no stability/RIF side effects).
|
|
94
|
+
const hits = service.recall(query, { k: 3, sessionId, reinforce: false })
|
|
78
95
|
return reply(200, {
|
|
79
96
|
query,
|
|
80
97
|
memories: hits.map((hit) => ({
|
package/lib/client.js
CHANGED
|
@@ -18,14 +18,20 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
var useRef = react.useRef
|
|
19
19
|
var createElement = react.createElement
|
|
20
20
|
|
|
21
|
-
var PORTS =
|
|
21
|
+
var PORTS = Array.from({ length: 10 }, function (_, i) { return 18734 + i })
|
|
22
22
|
var activePort = 0
|
|
23
23
|
var POLL_MS = 5000
|
|
24
24
|
|
|
25
25
|
// Probe the standard port first; if another app owns it, walk the range.
|
|
26
|
+
// Optional shared secret: set `config.api.token` and `localStorage['cerebrum.token']`.
|
|
26
27
|
var get = function (path) {
|
|
28
|
+
var token = null
|
|
29
|
+
try {
|
|
30
|
+
token = localStorage.getItem('cerebrum.token')
|
|
31
|
+
} catch (err) {}
|
|
32
|
+
var init = token ? { headers: { 'x-cerebrum-token': token } } : undefined
|
|
27
33
|
var attempt = function (index) {
|
|
28
|
-
return fetch('http://127.0.0.1:' + PORTS[index] + path)
|
|
34
|
+
return fetch('http://127.0.0.1:' + PORTS[index] + path, init)
|
|
29
35
|
.then(function (r) {
|
|
30
36
|
if (!r.ok) return null
|
|
31
37
|
activePort = index
|
|
@@ -67,7 +73,14 @@ window.__ModuleLoader__.load({
|
|
|
67
73
|
var [state, setState] = useState(null)
|
|
68
74
|
var [open, setOpen] = useState(false)
|
|
69
75
|
var [pinned, setPinned] = useState(false)
|
|
70
|
-
var [pos, setPos] = useState(
|
|
76
|
+
var [pos, setPos] = useState(function () {
|
|
77
|
+
try {
|
|
78
|
+
var raw = localStorage.getItem('cerebrum.pos')
|
|
79
|
+
return raw ? JSON.parse(raw) : null
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
})
|
|
71
84
|
var [uiEnabled, setUiEnabled] = useState(true)
|
|
72
85
|
var [hidden, setHidden] = useState(function () {
|
|
73
86
|
try {
|
|
@@ -283,12 +296,19 @@ window.__ModuleLoader__.load({
|
|
|
283
296
|
if (event.target && event.target.getAttribute && event.target.getAttribute('data-no-drag') === '1') return
|
|
284
297
|
var rect = panelRef.current ? panelRef.current.getBoundingClientRect() : { left: 0, top: 0 }
|
|
285
298
|
dragRef.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top }
|
|
299
|
+
var latest = null
|
|
286
300
|
var move = function (ev) {
|
|
287
|
-
|
|
301
|
+
latest = { x: Math.max(8, ev.clientX - dragRef.current.dx), y: Math.max(8, ev.clientY - dragRef.current.dy) }
|
|
302
|
+
setPos(latest)
|
|
288
303
|
}
|
|
289
304
|
var up = function () {
|
|
290
305
|
window.removeEventListener('mousemove', move)
|
|
291
306
|
window.removeEventListener('mouseup', up)
|
|
307
|
+
if (latest) {
|
|
308
|
+
try {
|
|
309
|
+
localStorage.setItem('cerebrum.pos', JSON.stringify(latest))
|
|
310
|
+
} catch (err) {}
|
|
311
|
+
}
|
|
292
312
|
}
|
|
293
313
|
window.addEventListener('mousemove', move)
|
|
294
314
|
window.addEventListener('mouseup', up)
|
package/lib/core/affect.js
CHANGED
|
@@ -139,8 +139,10 @@ export class AffectSystem {
|
|
|
139
139
|
this.decay(now)
|
|
140
140
|
const a = {}
|
|
141
141
|
for (const dim of APPRAISAL_DIMS) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
const raw = Number(dims[dim])
|
|
143
|
+
const value = Number.isFinite(raw) ? raw : 0
|
|
144
|
+
if (dim === 'congruence' || dim === 'norm') a[dim] = clamp(value, -1, 1)
|
|
145
|
+
else a[dim] = clamp(value)
|
|
144
146
|
}
|
|
145
147
|
// Trait gain: neuroticism amplifies threat appraisal, extraversion amplifies relevance of social events.
|
|
146
148
|
const P = this.personality?.params() ?? {}
|
package/lib/core/judge.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { clamp, prospectValue, sigmoid, softmax } from './math.js'
|
|
17
|
+
import { tokenize } from './sdr.js'
|
|
17
18
|
|
|
18
19
|
const HIGH_STAKES = /(删除|删掉|覆盖|格式化|上线|发布|生产|数据库|密码|密钥|转账|支付|合同|法律|医疗|诊断| irreversible|delete|drop table|force push|rm -rf|production|deploy|password|secret|payment)/i
|
|
19
20
|
const IRREVERSIBLE = /(删除|覆盖|格式化|清空|永久|drop|delete|truncate|rm -rf|force|覆写)/i
|
|
@@ -47,7 +48,8 @@ export class Judge {
|
|
|
47
48
|
if (q.length === 0) throw new Error('judge requires a question')
|
|
48
49
|
|
|
49
50
|
const highStakes = HIGH_STAKES.test(q)
|
|
50
|
-
const
|
|
51
|
+
const stakesInput = stakes === 'low' || stakes === 'high' ? stakes : 'medium'
|
|
52
|
+
const stakesLevel = highStakes ? 'high' : stakesInput
|
|
51
53
|
const magnitude = STAKES_VALUE[stakesLevel] ?? 0.55
|
|
52
54
|
const baseLoss = P.lossAversion * (1 + 0.25 * magnitude)
|
|
53
55
|
const lossAversion = A ? A.lossAversion(baseLoss) : baseLoss
|
|
@@ -232,8 +234,8 @@ export class Judge {
|
|
|
232
234
|
if (!best || Math.abs(score) > Math.abs(best.value)) best = { option: candidate.option, value: score }
|
|
233
235
|
}
|
|
234
236
|
if (!best) return null
|
|
235
|
-
//
|
|
236
|
-
return Math.
|
|
237
|
+
// Report the option with the strongest signed gut pull (magnitude decides).
|
|
238
|
+
return Math.abs(best.value) > 0.01 ? best : null
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
_stance({ gut, evidence, P, magnitude, q }) {
|
|
@@ -278,10 +280,11 @@ export class Judge {
|
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
/**
|
|
283
|
+
/** Lexical overlap in [0, 1] using the same CJK-aware tokenizer as memory.
|
|
284
|
+
* Splitting on whitespace would score every Chinese sentence pair as 0. */
|
|
282
285
|
const simTokens = (a, b) => {
|
|
283
|
-
const tokensA = new Set(String(a)
|
|
284
|
-
const tokensB = new Set(String(b)
|
|
286
|
+
const tokensA = new Set(tokenize(String(a)))
|
|
287
|
+
const tokensB = new Set(tokenize(String(b)))
|
|
285
288
|
if (tokensA.size === 0 || tokensB.size === 0) return 0
|
|
286
289
|
let inter = 0
|
|
287
290
|
for (const t of tokensA) if (tokensB.has(t)) inter++
|
package/lib/core/math.js
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* retrieval, appraisal and judgment can be replayed and unit-tested.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const clamp = (x, lo = 0, hi = 1) =>
|
|
6
|
+
export const clamp = (x, lo = 0, hi = 1) => {
|
|
7
|
+
const n = Number(x)
|
|
8
|
+
if (Number.isNaN(n)) return lo
|
|
9
|
+
return n < lo ? lo : n > hi ? hi : n
|
|
10
|
+
}
|
|
7
11
|
|
|
8
12
|
export const lerp = (a, b, t) => a + (b - a) * t
|
|
9
13
|
|
package/lib/core/memory.js
CHANGED
|
@@ -593,13 +593,14 @@ export class MemoryStore {
|
|
|
593
593
|
recall(query, options = {}) {
|
|
594
594
|
const now = Number(options.now) || Date.now()
|
|
595
595
|
const k = Math.max(1, Number(options.k) || 5)
|
|
596
|
+
const queryText = String(query ?? '').slice(0, 2000)
|
|
596
597
|
const moodValence = Number(options.moodValence ?? 0)
|
|
597
598
|
const moodBias = clamp(Number(options.moodBias ?? 0.18))
|
|
598
599
|
const kinds = Array.isArray(options.kinds) && options.kinds.length > 0 ? new Set(options.kinds) : null
|
|
599
600
|
if (this.episodes.length === 0) return []
|
|
600
601
|
|
|
601
|
-
const cue = this.encoder.encode(
|
|
602
|
-
const cueConcepts = selectConcepts(this.encoder,
|
|
602
|
+
const cue = this.encoder.encode(queryText)
|
|
603
|
+
const cueConcepts = selectConcepts(this.encoder, queryText, 8)
|
|
603
604
|
const seeds = new Map()
|
|
604
605
|
for (const concept of cueConcepts) {
|
|
605
606
|
const node = this.concepts.get(concept)
|
|
@@ -620,7 +621,7 @@ export class MemoryStore {
|
|
|
620
621
|
for (const ep of [...this.episodes].sort((a, b) => b.importance - a.importance).slice(0, 60)) candidates.add(ep.id)
|
|
621
622
|
// Lexical layer: exact terms (error codes, names, versions) surface even when
|
|
622
623
|
// the semantic code misses them.
|
|
623
|
-
const lexScores = this.bm25.search(
|
|
624
|
+
const lexScores = this.bm25.search(queryText, { limit: 80 })
|
|
624
625
|
let lexMax = 0
|
|
625
626
|
for (const score of lexScores.values()) lexMax = Math.max(lexMax, score)
|
|
626
627
|
for (const id of lexScores.keys()) candidates.add(id)
|
|
@@ -641,7 +642,7 @@ export class MemoryStore {
|
|
|
641
642
|
if (completion.winners.length > 0) this.stats.completions++
|
|
642
643
|
const currentContext = options.contextCode?.size > 0 ? options.contextCode : this.prevContext
|
|
643
644
|
|
|
644
|
-
const rng = makeRng((now ^ (
|
|
645
|
+
const rng = makeRng((now ^ (queryText.length * 2654435761)) >>> 0)
|
|
645
646
|
const noise = () => gaussian(rng) * 0.04
|
|
646
647
|
const scored = []
|
|
647
648
|
for (const ep of pool) {
|
|
@@ -694,7 +695,8 @@ export class MemoryStore {
|
|
|
694
695
|
const picked = scored.slice(0, k)
|
|
695
696
|
if (options.reinforce !== false && picked.length > 0) {
|
|
696
697
|
// Retrieval strengthens the winner; a close competitor is suppressed instead
|
|
697
|
-
// (retrieval-induced forgetting)
|
|
698
|
+
// (retrieval-induced forgetting). Far competitors are left untouched — a
|
|
699
|
+
// retrieval is not a rehearsal of everything that was merely considered.
|
|
698
700
|
this._rehearse(picked[0].episode, now)
|
|
699
701
|
for (let i = 1; i < picked.length; i++) {
|
|
700
702
|
const hit = picked[i]
|
|
@@ -702,8 +704,6 @@ export class MemoryStore {
|
|
|
702
704
|
// Suppression acts on the trace's stability, not a cosmetic score.
|
|
703
705
|
hit.episode.stability = Math.max(0.25, hit.episode.stability * 0.96)
|
|
704
706
|
hit.episode.strength = clamp(0.3 + 0.7 * Math.min(1, Math.log1p(hit.episode.stability) / Math.log1p(365)))
|
|
705
|
-
} else {
|
|
706
|
-
hit.episode.stability = Math.min(3650, hit.episode.stability * 1.01)
|
|
707
707
|
}
|
|
708
708
|
}
|
|
709
709
|
// Context reinstatement closes the loop: the next retrieval starts here.
|
|
@@ -784,7 +784,7 @@ export class MemoryStore {
|
|
|
784
784
|
ep.interferences += 1
|
|
785
785
|
this._recordReview(ep, 'interference', at)
|
|
786
786
|
this.stats.interferences++
|
|
787
|
-
|
|
787
|
+
// No episode rewrite: the review record carries S/D and is replayed on load.
|
|
788
788
|
}
|
|
789
789
|
}
|
|
790
790
|
|
|
@@ -805,7 +805,7 @@ export class MemoryStore {
|
|
|
805
805
|
*/
|
|
806
806
|
probe(query, { now = Date.now(), limit = 600 } = {}) {
|
|
807
807
|
if (this.episodes.length === 0) return { similarity: 0, episode: null }
|
|
808
|
-
const cue = this.encoder.encode(query)
|
|
808
|
+
const cue = this.encoder.encode(String(query ?? '').slice(0, 2000))
|
|
809
809
|
let best = { similarity: 0, episode: null }
|
|
810
810
|
for (const ep of this.episodes.slice(-limit)) {
|
|
811
811
|
const similarity = this.encoder.similarity(cue, ep.code)
|
|
@@ -834,13 +834,19 @@ export class MemoryStore {
|
|
|
834
834
|
update(id, patch = {}) {
|
|
835
835
|
const ep = this.byId.get(id)
|
|
836
836
|
if (!ep) return null
|
|
837
|
+
const textChanged = typeof patch.text === 'string' && patch.text.trim().length > 0
|
|
837
838
|
if (typeof patch.summary === 'string' && patch.summary.trim().length > 0) ep.summary = truncate(patch.summary.trim(), 240)
|
|
838
|
-
if (
|
|
839
|
+
if (textChanged) ep.text = truncate(patch.text.trim(), 4000)
|
|
839
840
|
if (Number.isFinite(patch.importance)) ep.importance = clamp(patch.importance)
|
|
840
841
|
if (Array.isArray(patch.tags)) ep.tags = patch.tags.slice(0, 12).map(String)
|
|
841
842
|
if (typeof patch.resident === 'boolean') ep.resident = patch.resident
|
|
842
843
|
ep.consolidated = false
|
|
843
844
|
this.dirty = true
|
|
845
|
+
// Lexical index must follow the text, otherwise BM25 keeps serving stale terms.
|
|
846
|
+
if (textChanged) {
|
|
847
|
+
this.bm25.remove(id)
|
|
848
|
+
this.bm25.add(id, ep.text)
|
|
849
|
+
}
|
|
844
850
|
return ep
|
|
845
851
|
}
|
|
846
852
|
|
|
@@ -870,6 +876,8 @@ export class MemoryStore {
|
|
|
870
876
|
if (outcome && typeof outcome === 'object') ep.outcome = { success: outcome.success === true, errors: Number(outcome.errors ?? 0) }
|
|
871
877
|
if (typeof note === 'string' && note.trim().length > 0) {
|
|
872
878
|
ep.text = truncate(`${ep.text}\n[重构] ${note.trim()}`, 4000)
|
|
879
|
+
this.bm25.remove(ep.id)
|
|
880
|
+
this.bm25.add(ep.id, ep.text)
|
|
873
881
|
}
|
|
874
882
|
ep.reconsolidations += 1
|
|
875
883
|
ep.consolidated = false
|
|
@@ -970,10 +978,12 @@ export class MemoryStore {
|
|
|
970
978
|
this._recordReview(ep, 'schema', now)
|
|
971
979
|
}
|
|
972
980
|
// SOAR chunking: a consistent schema becomes a reusable procedure.
|
|
973
|
-
// `when` =
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
981
|
+
// `when` = tokens from the cluster's summaries, so the trigger reads
|
|
982
|
+
// like the situation instead of a random high-frequency trigram.
|
|
983
|
+
const summaryTokens = this.encoder.topTokens(episodes.map((ep) => ep.summary).join(' '), 12)
|
|
984
|
+
const common = summaryTokens
|
|
985
|
+
.filter((token) => token.length >= 2 && token.length <= 4 && !/^[才过次第再又把被着的地得]/.test(token))
|
|
986
|
+
.filter((token) => episodes.filter((ep) => ep.concepts.includes(token) || ep.summary.includes(token)).length >= Math.ceil(episodes.length / 2))
|
|
977
987
|
.slice(0, 3)
|
|
978
988
|
this.upsertSkill({ when: common.length > 0 ? common : [concept], do: node.gist, source: 'auto', strength: 0.3 + 0.1 * Math.min(episodes.length, 5), now })
|
|
979
989
|
}
|
|
@@ -1037,7 +1047,12 @@ export class MemoryStore {
|
|
|
1037
1047
|
for (const ep of this.episodes) if (!ep.consolidated) ep.replayCount++
|
|
1038
1048
|
|
|
1039
1049
|
this.stats.consolidated++
|
|
1050
|
+
// Schema/consolidated flags are structural: unlike stability they cannot be
|
|
1051
|
+
// replayed from the review log, so the episode log must be rewritten.
|
|
1052
|
+
if (applied.length > 0 || created.length > 0) this.dirty = true
|
|
1040
1053
|
this.prune(now)
|
|
1054
|
+
this._pruneConcepts(now)
|
|
1055
|
+
this._pruneEdges(now)
|
|
1041
1056
|
this.flush()
|
|
1042
1057
|
// Decision receipt: the same inputs reproduce the same hash (auditable consolidation).
|
|
1043
1058
|
const receipt = {
|
|
@@ -1048,7 +1063,9 @@ export class MemoryStore {
|
|
|
1048
1063
|
episodes: this.episodes.length,
|
|
1049
1064
|
concepts: this.concepts.size,
|
|
1050
1065
|
}
|
|
1051
|
-
|
|
1066
|
+
// Hash covers the decision inputs only (not the wall clock), so the same
|
|
1067
|
+
// consolidation is reproducible and verifiable.
|
|
1068
|
+
receipt.hash = crypto.createHash('sha256').update(JSON.stringify({ stage, applied: applied.length, created: created.length, episodes: receipt.episodes, concepts: receipt.concepts })).digest('hex').slice(0, 16)
|
|
1052
1069
|
this.receiptLog.append(receipt)
|
|
1053
1070
|
this.stats.receipts = this.receiptLog.items.length
|
|
1054
1071
|
return {
|
|
@@ -1068,12 +1085,15 @@ export class MemoryStore {
|
|
|
1068
1085
|
/** Forgetting curve + capacity eviction. */
|
|
1069
1086
|
prune(now = Date.now()) {
|
|
1070
1087
|
if (this.episodes.length <= this.maxEpisodes * 0.9) return []
|
|
1071
|
-
|
|
1088
|
+
// Resident preferences and schema-backed traces are never evicted.
|
|
1089
|
+
const pinned = this.episodes.filter((ep) => ep.resident || ep.schema)
|
|
1090
|
+
const candidates = this.episodes.filter((ep) => !ep.resident && !ep.schema)
|
|
1091
|
+
const scored = candidates.map((ep) => {
|
|
1072
1092
|
const keep = this.retention(ep, now) * (0.35 + 0.65 * ep.importance) + 0.02 * Math.log1p(ep.retrievals)
|
|
1073
1093
|
return { ep, keep }
|
|
1074
1094
|
})
|
|
1075
1095
|
scored.sort((a, b) => b.keep - a.keep)
|
|
1076
|
-
const keepCount = Math.floor(this.maxEpisodes * 0.9)
|
|
1096
|
+
const keepCount = Math.max(0, Math.floor(this.maxEpisodes * 0.9) - pinned.length)
|
|
1077
1097
|
const kept = scored.slice(0, keepCount).map(({ ep }) => ep)
|
|
1078
1098
|
const dropped = scored.slice(keepCount)
|
|
1079
1099
|
for (const { ep } of dropped) {
|
|
@@ -1083,8 +1103,8 @@ export class MemoryStore {
|
|
|
1083
1103
|
node.gist = node.gist || truncate(`${ep.summary}(已压缩)`, 200)
|
|
1084
1104
|
}
|
|
1085
1105
|
}
|
|
1086
|
-
this.episodes = kept
|
|
1087
|
-
this.byId = new Map(
|
|
1106
|
+
this.episodes = [...kept, ...pinned]
|
|
1107
|
+
this.byId = new Map(this.episodes.map((ep) => [ep.id, ep]))
|
|
1088
1108
|
for (const id of dropped.map(({ ep }) => ep.id)) this.bm25.remove(id)
|
|
1089
1109
|
this._rebuildConceptIndex()
|
|
1090
1110
|
this.dirty = true
|
|
@@ -1092,6 +1112,45 @@ export class MemoryStore {
|
|
|
1092
1112
|
return dropped.map(({ ep }) => ep.id)
|
|
1093
1113
|
}
|
|
1094
1114
|
|
|
1115
|
+
/** Cortical-style fan-out limit: keep the strongest outgoing edges and drop
|
|
1116
|
+
* pure noise (weak AND seen only once). Bounds graph size without losing
|
|
1117
|
+
* strong associations. */
|
|
1118
|
+
_pruneEdges(now = Date.now(), { fanout = 40, weak = 0.03, totalCap = 120000 } = {}) {
|
|
1119
|
+
let removed = 0
|
|
1120
|
+
const bySource = new Map()
|
|
1121
|
+
for (const [key, edge] of this.edges) {
|
|
1122
|
+
const source = key.split('\u0001')[0]
|
|
1123
|
+
let list = bySource.get(source)
|
|
1124
|
+
if (!list) bySource.set(source, (list = []))
|
|
1125
|
+
list.push([key, edge])
|
|
1126
|
+
}
|
|
1127
|
+
for (const [, list] of bySource) {
|
|
1128
|
+
if (list.length <= fanout) continue
|
|
1129
|
+
list.sort((a, b) => b[1].w - a[1].w)
|
|
1130
|
+
// A hard fan-out limit (cortical connectivity is sparse): keep the strongest
|
|
1131
|
+
// outgoing links and drop the rest regardless of absolute weight.
|
|
1132
|
+
for (const [key] of list.slice(fanout)) {
|
|
1133
|
+
this.edges.delete(key)
|
|
1134
|
+
removed++
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
for (const [key, edge] of [...this.edges]) {
|
|
1138
|
+
if (edge.w < weak && edge.count <= 1) {
|
|
1139
|
+
this.edges.delete(key)
|
|
1140
|
+
removed++
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (this.edges.size > totalCap) {
|
|
1144
|
+
const weakest = [...this.edges.entries()].sort((a, b) => a[1].w - b[1].w)
|
|
1145
|
+
for (const [key] of weakest.slice(0, this.edges.size - totalCap)) {
|
|
1146
|
+
this.edges.delete(key)
|
|
1147
|
+
removed++
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (removed > 0) this._rebuildAdjacency()
|
|
1151
|
+
return removed
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1095
1154
|
topConcepts(limit = 10) {
|
|
1096
1155
|
return [...this.concepts.entries()]
|
|
1097
1156
|
.sort((a, b) => b[1].strength - a[1].strength || b[1].count - a[1].count)
|
|
@@ -1099,6 +1158,37 @@ export class MemoryStore {
|
|
|
1099
1158
|
.map(([name, node]) => ({ name, strength: round3(node.strength), count: node.count, gist: node.gist, schema: node.schema }))
|
|
1100
1159
|
}
|
|
1101
1160
|
|
|
1161
|
+
/** Conceptual garbage collection: drop orphaned weak concepts and cap the map. */
|
|
1162
|
+
_pruneConcepts(now = Date.now(), cap = 6000) {
|
|
1163
|
+
const referenced = new Set(this.conceptIndex.keys())
|
|
1164
|
+
const cutoff = now - 90 * 24 * 60 * 60 * 1000
|
|
1165
|
+
let removed = 0
|
|
1166
|
+
for (const [name, node] of [...this.concepts]) {
|
|
1167
|
+
if (referenced.has(name) || node.schema) continue
|
|
1168
|
+
if (node.lastAt > cutoff) continue
|
|
1169
|
+
if (node.strength >= 0.5 && (node.hot ?? 0) > 0.05) continue
|
|
1170
|
+
this.concepts.delete(name)
|
|
1171
|
+
removed++
|
|
1172
|
+
}
|
|
1173
|
+
if (this.concepts.size > cap) {
|
|
1174
|
+
const weakest = [...this.concepts.entries()]
|
|
1175
|
+
.sort((a, b) => a[1].strength + (a[1].hot ?? 0) - (b[1].strength + (b[1].hot ?? 0)))
|
|
1176
|
+
for (const [name] of weakest.slice(0, this.concepts.size - cap)) {
|
|
1177
|
+
if (referenced.has(name)) continue
|
|
1178
|
+
this.concepts.delete(name)
|
|
1179
|
+
removed++
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (removed > 0) {
|
|
1183
|
+
for (const key of [...this.edges.keys()]) {
|
|
1184
|
+
const [a, b] = key.split('\u0001')
|
|
1185
|
+
if (!this.concepts.has(a) && !this.concepts.has(b)) this.edges.delete(key)
|
|
1186
|
+
}
|
|
1187
|
+
this._rebuildAdjacency()
|
|
1188
|
+
}
|
|
1189
|
+
return removed
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1102
1192
|
/** Resident layer: preferences, working agreements and schema memories. */
|
|
1103
1193
|
resident(limit = 6) {
|
|
1104
1194
|
return this.episodes
|
package/lib/service.js
CHANGED
|
@@ -227,12 +227,54 @@ export class CerebrumService {
|
|
|
227
227
|
_session(id) {
|
|
228
228
|
let state = this.sessions.get(id)
|
|
229
229
|
if (!state) {
|
|
230
|
-
state = { id, turn: 0, user: [], assistant: [], tools: [], errors: 0, success: 0, startedAt: Date.now(), lastUserText: '', events: 0, cwd: null, dir: null, context: null, wm: [], segment: null, lastTurnAt: 0, lastBoundaryAt: 0, turnMemory: { read: [], wrote: [] }, prevConcepts: [], lastUserSeq: null, persona: null }
|
|
230
|
+
state = { id, turn: 0, user: [], assistant: [], tools: [], errors: 0, success: 0, startedAt: Date.now(), lastUserText: '', events: 0, cwd: null, dir: null, context: null, wm: [], segment: null, pendingChecked: false, lastTurnAt: 0, lastBoundaryAt: 0, turnMemory: { read: [], wrote: [] }, prevConcepts: [], lastUserSeq: null, persona: null }
|
|
231
231
|
this.sessions.set(id, state)
|
|
232
232
|
}
|
|
233
233
|
return state
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// ---- crash-safe pending segment ------------------------------------------
|
|
237
|
+
|
|
238
|
+
_pendingFile(state) {
|
|
239
|
+
const store = this._storeForState(state)
|
|
240
|
+
return path.join(store.dir, 'pending', `${String(state.id).replace(/[^\w.-]/g, '_')}.json`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_savePending(state, segment) {
|
|
244
|
+
if (!state || !segment) return
|
|
245
|
+
try {
|
|
246
|
+
const file = this._pendingFile(state)
|
|
247
|
+
ensureDir(path.dirname(file))
|
|
248
|
+
writeJson(file, { savedAt: Date.now(), segment })
|
|
249
|
+
} catch (error) {
|
|
250
|
+
this.logger?.warn?.('[cerebrum] pending segment write failed:', error?.message ?? error)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_loadPending(state) {
|
|
255
|
+
try {
|
|
256
|
+
const file = this._pendingFile(state)
|
|
257
|
+
if (!fs.existsSync(file)) return null
|
|
258
|
+
const data = readJson(file, null)
|
|
259
|
+
const segment = data?.segment
|
|
260
|
+
if (!segment || !Array.isArray(segment.turns) || segment.turns.length === 0) return null
|
|
261
|
+
// Guard against an ancient pending file resurfacing after a long break.
|
|
262
|
+
const age = Date.now() - Number(segment.lastAt ?? 0)
|
|
263
|
+
if (age > 24 * 60 * 60 * 1000) return null
|
|
264
|
+
return segment
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_clearPending(state) {
|
|
271
|
+
try {
|
|
272
|
+
fs.rmSync(this._pendingFile(state), { force: true })
|
|
273
|
+
} catch {
|
|
274
|
+
/* best effort */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
236
278
|
/**
|
|
237
279
|
* Observe one session event. Never throws into the loop: a broken memory
|
|
238
280
|
* write must not fail a turn.
|
|
@@ -247,6 +289,12 @@ export class CerebrumService {
|
|
|
247
289
|
state.cwd = cwd
|
|
248
290
|
state.dir = this._storeForCwd(cwd).dir
|
|
249
291
|
}
|
|
292
|
+
// Crash recovery: once the workspace is known, restore an open event that a
|
|
293
|
+
// previous process persisted (writes happen per turn, see _savePending).
|
|
294
|
+
if (!state.pendingChecked) {
|
|
295
|
+
state.pendingChecked = true
|
|
296
|
+
if (!state.segment) state.segment = this._loadPending(state)
|
|
297
|
+
}
|
|
250
298
|
if (state.events++ > MAX_PENDING_EVENTS) return
|
|
251
299
|
|
|
252
300
|
switch (event.type) {
|
|
@@ -328,6 +376,7 @@ export class CerebrumService {
|
|
|
328
376
|
if (!this.config.autoCapture.enabled) return undefined
|
|
329
377
|
if (reason?.kind === 'aborted') {
|
|
330
378
|
state.segment = null
|
|
379
|
+
this._clearPending(state)
|
|
331
380
|
return undefined
|
|
332
381
|
}
|
|
333
382
|
|
|
@@ -440,6 +489,9 @@ export class CerebrumService {
|
|
|
440
489
|
segment.toolCount += tools.length
|
|
441
490
|
segment.lastAt = now
|
|
442
491
|
segment.code = eventCode
|
|
492
|
+
// Crash-safe: the open event is persisted after every turn, so a host crash
|
|
493
|
+
// costs at most the current turn instead of the whole open event.
|
|
494
|
+
this._savePending(state, segment)
|
|
443
495
|
|
|
444
496
|
// A high-stakes or failed turn is its own episode; do not dissolve it into a
|
|
445
497
|
// longer event where its lesson would be averaged away.
|
|
@@ -514,6 +566,7 @@ export class CerebrumService {
|
|
|
514
566
|
rawSeqs: segment.seqs,
|
|
515
567
|
})
|
|
516
568
|
this._noteTurnMemory(state, 'wrote', { id: episode.id, summary: episode.summary })
|
|
569
|
+
this._clearPending(state)
|
|
517
570
|
this._updateSelf({ episode, emotion: emotion ?? this.affect.emotion, errors: segment.errors, toolCount: segment.toolCount, store })
|
|
518
571
|
this.turnCount++
|
|
519
572
|
this._schedulePersist()
|
|
@@ -564,12 +617,13 @@ export class CerebrumService {
|
|
|
564
617
|
|
|
565
618
|
// ---- operations exposed to tools -----------------------------------------
|
|
566
619
|
|
|
567
|
-
recall(query, { k = 5, kinds, sessionId } = {}) {
|
|
620
|
+
recall(query, { k = 5, kinds, sessionId, reinforce = true } = {}) {
|
|
568
621
|
const store = this._storeForSession(sessionId)
|
|
569
622
|
const state = sessionId ? this.sessions.get(sessionId) : null
|
|
570
623
|
const hits = store.recall(String(query ?? ''), {
|
|
571
624
|
k,
|
|
572
625
|
kinds,
|
|
626
|
+
reinforce,
|
|
573
627
|
moodValence: this.affect.valence,
|
|
574
628
|
moodBias: this.config.affect.moodBias,
|
|
575
629
|
contextCode: state?.context,
|
|
@@ -694,13 +748,14 @@ export class CerebrumService {
|
|
|
694
748
|
}
|
|
695
749
|
}
|
|
696
750
|
|
|
697
|
-
remember({ text, summary, importance, tags, kind = 'explicit', resident = false, sessionId } = {}) {
|
|
751
|
+
remember({ text, summary, importance, tags, kind = 'explicit', resident = false, force = false, sessionId } = {}) {
|
|
698
752
|
const store = this._storeForSession(sessionId)
|
|
699
753
|
const state = sessionId ? this.sessions.get(sessionId) : null
|
|
700
754
|
const now = Date.now()
|
|
701
755
|
// Idempotent write: a near-duplicate strengthens the existing trace instead
|
|
702
756
|
// of piling up a second copy (mnemon's dedup, memento's conditional write).
|
|
703
|
-
|
|
757
|
+
// `force` bypasses this when the user explicitly wants a separate memory.
|
|
758
|
+
const duplicate = force ? null : store.findDuplicate(text)
|
|
704
759
|
if (duplicate) {
|
|
705
760
|
const updated = store.reconsolidate(duplicate.ep.id, {
|
|
706
761
|
summary: summary ? String(summary) : duplicate.ep.summary,
|
|
@@ -938,11 +993,14 @@ export class CerebrumService {
|
|
|
938
993
|
return entry.persona
|
|
939
994
|
}
|
|
940
995
|
|
|
941
|
-
/** Read a file for tool inputs (character-card PNGs). */
|
|
996
|
+
/** Read a file for tool inputs (character-card PNGs). Capped to avoid OOM. */
|
|
942
997
|
readFile(filePath) {
|
|
943
998
|
if (typeof filePath !== 'string' || filePath.length === 0) return null
|
|
944
999
|
try {
|
|
945
|
-
|
|
1000
|
+
const resolved = path.resolve(filePath)
|
|
1001
|
+
const stat = fs.statSync(resolved)
|
|
1002
|
+
if (!stat.isFile() || stat.size > 10 * 1024 * 1024) return null
|
|
1003
|
+
return fs.readFileSync(resolved)
|
|
946
1004
|
} catch {
|
|
947
1005
|
return null
|
|
948
1006
|
}
|
|
@@ -1025,7 +1083,12 @@ export class CerebrumService {
|
|
|
1025
1083
|
|
|
1026
1084
|
let text = lines.join('\n')
|
|
1027
1085
|
const max = this.config.prompt.maxChars
|
|
1028
|
-
if (text.length > max)
|
|
1086
|
+
if (text.length > max) {
|
|
1087
|
+
// Never split a surrogate pair (emoji) at the cut point.
|
|
1088
|
+
let cut = text.slice(0, max - 1)
|
|
1089
|
+
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
|
|
1090
|
+
text = `${cut}…`
|
|
1091
|
+
}
|
|
1029
1092
|
return text
|
|
1030
1093
|
}
|
|
1031
1094
|
|
package/lib/tools.js
CHANGED
|
@@ -155,6 +155,7 @@ export const buildTools = (service) => [
|
|
|
155
155
|
summary: { type: 'string', description: '一句话摘要,用于未来检索展示' },
|
|
156
156
|
importance: { type: 'number', description: '重要度 0..1,默认 0.6' },
|
|
157
157
|
resident: { type: 'boolean', description: '标记为常驻记忆(偏好/约定),每轮注入;默认 false' },
|
|
158
|
+
force: { type: 'boolean', description: '跳过近重复合并,强制写入一条新记忆;默认 false' },
|
|
158
159
|
tags: { type: 'array', description: '标签', items: { type: 'string' } },
|
|
159
160
|
kind: { type: 'string', enum: ['explicit', 'semantic', 'episodic'], description: '记忆类型,默认 explicit' },
|
|
160
161
|
},
|
|
@@ -170,6 +171,7 @@ export const buildTools = (service) => [
|
|
|
170
171
|
summary: args.summary,
|
|
171
172
|
importance: args.importance,
|
|
172
173
|
resident: args.resident === true,
|
|
174
|
+
force: args.force === true,
|
|
173
175
|
tags: args.tags,
|
|
174
176
|
kind: args.kind ?? 'explicit',
|
|
175
177
|
sessionId: sessionIdOf(exec),
|
|
@@ -430,6 +432,15 @@ export const buildTools = (service) => [
|
|
|
430
432
|
episodeIds: { type: 'array', items: { type: 'string' } },
|
|
431
433
|
gist: { type: 'string' },
|
|
432
434
|
lesson: { type: 'string' },
|
|
435
|
+
skill: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
description: '可选:沉淀一条程序性规则(when→do),来源标为 model',
|
|
438
|
+
properties: {
|
|
439
|
+
when: { type: 'array', items: { type: 'string' }, description: '触发情境关键词' },
|
|
440
|
+
do: { type: 'string', description: '做法/动作' },
|
|
441
|
+
},
|
|
442
|
+
required: ['do'],
|
|
443
|
+
},
|
|
433
444
|
},
|
|
434
445
|
required: ['episodeIds', 'gist'],
|
|
435
446
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cerebrum",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Brain-inspired cognitive architecture for DeepSeek Harness: hippocampal episodic memory, cortical semantic memory, spreading-activation recall, appraisal-based emotion, neuromodulator state, Big Five personality and dual-process judgment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"pet-hook": "node examples/pet-hook.mjs",
|
|
93
93
|
"privacy": "node scripts/privacy-check.mjs",
|
|
94
94
|
"release": "node scripts/release.mjs",
|
|
95
|
-
"market": "node scripts/check-market.mjs"
|
|
95
|
+
"market": "node scripts/check-market.mjs",
|
|
96
|
+
"bench": "node scripts/bench.mjs"
|
|
96
97
|
}
|
|
97
98
|
}
|