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 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 # 65 个单元 + 集成测试(node: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
  ![认知状态面板](docs/assets/cerebrum-dashboard.png)
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.2` 已上线(latest;0.1.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 (req.method === 'OPTIONS') return send(res, 204, {}, allowedOrigin)
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
- const hits = service.recall(query, { k: 3, sessionId })
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 = [18734, 18735, 18736, 18737]
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(null)
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
- setPos({ x: Math.max(8, ev.clientX - dragRef.current.dx), y: Math.max(8, ev.clientY - dragRef.current.dy) })
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)
@@ -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
- if (dim === 'congruence' || dim === 'norm') a[dim] = clamp(Number(dims[dim] ?? 0), -1, 1)
143
- else a[dim] = clamp(Number(dims[dim] ?? 0))
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 stakesLevel = highStakes ? 'high' : stakes
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
- // Only report a gut preference when it actually has a sign.
236
- return Math.sign(best.value) === Math.sign(gut) && Math.abs(best.value) > 0.01 ? best : best
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
- /** Cheap lexical overlap similarity in [0, 1] (no encoder dependency). */
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).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0))
284
- const tokensB = new Set(String(b).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0))
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) => (x < lo ? lo : x > hi ? hi : x)
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
 
@@ -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(query)
602
- const cueConcepts = selectConcepts(this.encoder, query, 8)
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(query, { limit: 80 })
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 ^ (query.length * 2654435761)) >>> 0)
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), not rehearsed as if it had been recalled.
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
- this.dirty = true
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 (typeof patch.text === 'string' && patch.text.trim().length > 0) ep.text = truncate(patch.text.trim(), 4000)
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` = the concepts shared by most episodes, so partial cues still match.
974
- const common = [...new Set(episodes.flatMap((ep) => ep.concepts))]
975
- .filter((candidate) => candidate.length >= 2 && candidate.length <= 4 && !/^[才过次第再又把被着]/.test(candidate))
976
- .filter((candidate) => episodes.filter((ep) => ep.concepts.includes(candidate)).length >= Math.ceil(episodes.length / 2))
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
- receipt.hash = crypto.createHash('sha256').update(JSON.stringify({ at: receipt.at, stage, applied: applied.length, created: created.length, episodes: receipt.episodes })).digest('hex').slice(0, 16)
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
- const scored = this.episodes.map((ep) => {
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(kept.map((ep) => [ep.id, ep]))
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
- const duplicate = store.findDuplicate(text)
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
- return fs.readFileSync(path.resolve(filePath))
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) text = `${text.slice(0, max - 1)}…`
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",
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
  }