dsh-pisces 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,14 +35,11 @@
35
35
 
36
36
  这些是**思路借鉴**,不是代码依赖;每个项目的代码各自独立,本仓库没有复制它们的实现。
37
37
 
38
- - **dsh-fish** —— 宿主骨架、四阶段门、叫醒只给处境;本项目从它的形态长出来。npm:<https://www.npmjs.com/package/dsh-fish>
39
- - **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文双字组检索、命中才注入。
40
- - **dsh-fish-river** —— 三层记忆(成长 / 关系 / 流水)、群聊不污染人格。
38
+ - **dsh-fish** —— 事件流 + 纯函数派生 + 面板形态,本项目的骨架从它长出来。npm:<https://www.npmjs.com/package/@ssamurias/dsh-fish>
39
+ - **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文检索。
40
+ - **Character Card V2** —— `export.js` 的 CCv2 导出格式。
41
41
  - **dsh-companion** —— 好感分档、关系产品化的样本。
42
- - **Character Card V2** —— 卡片格式兼容(CCv2 导出)。
43
- - **Generative Agents** —— memory stream + 定期 reflection。
44
- - **DUMA / 双流** —— 快慢两模型、冲突才二次调用。
45
- - **dsh-fish-tone / persona_core** —— 语病密度、单轮人格变化上限。
42
+ - **Generative Agents** —— memory stream + 定期 reflection 的启发。
46
43
  - **DeepSeek Harness** —— 宿主、插件体系、slots / systemPrompt / tools。
47
44
  - **双鱼座性格的公开资料**(经 Exa 检索整理)—— 她的性格起点。
48
45
  - **所有养过鱼的人** —— 社区里"把 AI 当室友养"的尝试。
package/client.js CHANGED
@@ -894,15 +894,12 @@ window.__ModuleLoader__.load({
894
894
  // -------------------------------------------------------------------------
895
895
 
896
896
  const THANKS = [
897
- // 应谢尽谢:dsh-fish 排第一;隐藏项目与作者前作不列。
898
- { name: 'dsh-fish', what: '宿主骨架、四阶段门、叫醒只给处境;本项目从它的形态长出来。', href: 'https://www.npmjs.com/package/dsh-fish' },
899
- { name: 'dsh-fish-memory', what: '记忆鲜活度与半衰期、中文双字组检索、命中才注入。' },
900
- { name: 'dsh-fish-river', what: '三层记忆(成长 / 关系 / 流水)、群聊不污染人格。' },
897
+ // 2026-11-10 定稿:八条;用户自己的插件/前作不鸣谢,dsh-fish 排第一。
898
+ { name: 'dsh-fish', what: '事件流 + 纯函数派生 + 面板形态,本项目的骨架从它长出来。', href: 'https://www.npmjs.com/package/@ssamurias/dsh-fish' },
899
+ { name: 'dsh-fish-memory', what: '记忆鲜活度与半衰期、中文检索。' },
900
+ { name: 'Character Card V2', what: '`export.js` 的 CCv2 导出格式。' },
901
901
  { name: 'dsh-companion', what: '好感分档、关系产品化的样本。' },
902
- { name: 'Character Card V2', what: '卡片格式兼容(CCv2 导出)。' },
903
- { name: 'Generative Agents', what: 'memory stream + 定期 reflection。' },
904
- { name: 'DUMA / 双流', what: '快慢两模型、冲突才二次调用。' },
905
- { name: 'dsh-fish-tone / persona_core', what: '语病密度、单轮人格变化上限。' },
902
+ { name: 'Generative Agents', what: 'memory stream + 定期 reflection 的启发。' },
906
903
  { name: 'DeepSeek Harness', what: '宿主、插件体系、slots / systemPrompt / tools。' },
907
904
  { name: '双鱼座性格的公开资料', what: '(经 Exa 检索整理)她的性格起点。' },
908
905
  { name: '所有养过鱼的人', what: '社区里"把 AI 当室友养"的尝试。' },
package/lib/attach.js CHANGED
@@ -15,11 +15,51 @@
15
15
  // 记进她的库。写库是副作用:失败只吞掉,绝不拖慢/改变流。
16
16
  import { buildSections as defaultBuildSections } from './persona.js'
17
17
  import { steerLines } from './guard.js'
18
+ import { search as defaultSearchMemory } from './search.js'
19
+ import { clip } from './util.js'
18
20
  import { MAX_TEXT } from './schema.js'
19
21
 
20
22
  // getStore() 返回 null(ready 还没到)时的重试间隔。定时器 unref,不拦进程退出。
21
23
  const STORE_RETRY_MS = 250
22
24
 
25
+ // 「此刻想起」:每轮按他刚说的话检索她的记忆,把最相关的几条只塞进这一次请求的
26
+ // system(不写事件、不落库、不进对话)。检索失败/没结果就当没这回事,绝不报错。
27
+ // 上限是保守值:单条 120 字、整段 800 字(约 800 tokens),超了截断。
28
+ export const RECALL_HEADER = '[此刻想起]'
29
+ export const RECALL_K = 5
30
+ export const RECALL_ITEM_CHARS = 120
31
+ export const RECALL_MAX_CHARS = 800
32
+
33
+ /**
34
+ * 把检索结果折成这一次的注入块;没有可用条目返回空串。
35
+ * 格式固定:第一行标题,之后每条一行 `- 原文`。单条 ≤120 字、整段 ≤800 字,
36
+ * 超了截断(不会抛错);不写"检索到/相关度"这类词,也不带 id/天数。
37
+ */
38
+ export function recallBlockFrom(items, {
39
+ k = RECALL_K,
40
+ max = RECALL_MAX_CHARS,
41
+ itemChars = RECALL_ITEM_CHARS,
42
+ } = {}) {
43
+ const list = Array.isArray(items) ? items : []
44
+ const lines = []
45
+ let used = RECALL_HEADER.length
46
+ let picked = 0
47
+ for (const it of list) {
48
+ if (picked >= k || used >= max) break
49
+ const text = clip(it?.text, itemChars)
50
+ if (!text) continue
51
+ // 留一个换行的位置;剩得太少就不塞了(半行没有意义)。
52
+ const room = max - used - 1
53
+ if (room < 4) break
54
+ const line = `- ${text}`
55
+ const body = line.length > room ? line.slice(0, room) : line
56
+ lines.push(body)
57
+ used += 1 + body.length
58
+ picked++
59
+ }
60
+ return lines.length ? [RECALL_HEADER, ...lines].join('\n') : ''
61
+ }
62
+
23
63
  // 开发者钥匙命中时追加到这一次请求 system 的那段话。
24
64
  // 只在这里出现;钥匙本身一个字都不进来,也不写事件、不写日志。
25
65
  export const DEV_PROMPT =
@@ -292,6 +332,11 @@ function resolveSystemPrompt(agent) {
292
332
  * waterfall,宿主每个会话的生成都会经过同一个监听器;只有它返回真才 recordHeard /
293
333
  * wrapUtterance。返回假时这条流原样 next():不改 options、不记事件、不包流。
294
334
  * 不传 = 默认不记(安全优先),并 log 一行「聊天落库:没有会话判据,跳过」。
335
+ * searchMemory 可选:`(store, {query,k,budget}) => {items:[{text}]}`,默认用 lib/search.js
336
+ * 的 search。只在 isHerSession 为真、库就绪、且清洗后的他有话时说:取 top 5、单条 ≤120 字、
337
+ * 整段 ≤800 字,折成 `[此刻想起]` 追加到这一次请求的 system 末尾(排在 guard/dev 之后)。
338
+ * 只进 system,不写事件、不落库、不进对话;检索失败/没结果 = 不注入、不报错、不影响流。
339
+ * 注入次数进 state.recallInjected(只给 /bridge 看次数,不留正文)。
295
340
  * @returns {((() => void) & { info?: object, attachInfo?: object })}
296
341
  * dispose():摘掉 agent/created 与 llm/stream 监听,幂等。
297
342
  * dispose.info / dispose.attachInfo:只读快照(enabled/storeReady/agentsSeen/sectionsAttached/stream…)。
@@ -303,6 +348,7 @@ export function attachToAgents(ctx, {
303
348
  isDevkey = null,
304
349
  shouldAttach = null,
305
350
  isHerSession = null,
351
+ searchMemory = defaultSearchMemory,
306
352
  config = {},
307
353
  log = () => {},
308
354
  now = () => Date.now(),
@@ -321,6 +367,8 @@ export function attachToAgents(ctx, {
321
367
  // 只给 /bridge 看的次数:命中几次,不给任何正文/原因。
322
368
  guardHits: 0,
323
369
  devHits: 0,
370
+ // 「此刻想起」注入过几次(只给 /bridge 看次数,不留正文)。
371
+ recallInjected: 0,
324
372
  // 聊天落库的可观测计数(只给次数/形状,不给正文):
325
373
  // streamsWrapped 包过的流数(只数她的流);streamsSkipped 判据判掉、原样放行的流数;
326
374
  // chunksSeen 看到的 chunk 总数;textParts 拼出文本的 chunk 数;
@@ -388,6 +436,7 @@ export function attachToAgents(ctx, {
388
436
  agentsNotHer: state.agentsNotHer,
389
437
  guardHits: state.guardHits,
390
438
  devHits: state.devHits,
439
+ recallInjected: state.recallInjected,
391
440
  // 平铺给 dispose.info,另外给一份 streamDebug 供 /bridge 白名单整块取。
392
441
  streamsWrapped: state.streamsWrapped,
393
442
  streamsSkipped: state.streamsSkipped,
@@ -662,6 +711,32 @@ export function attachToAgents(ctx, {
662
711
  if (appendChat('heard', 'owner', body)) entry.heard = body
663
712
  }
664
713
 
714
+ /**
715
+ * 按他刚说的话检索她的记忆,折成这一次的 `[此刻想起]` 块。
716
+ * 只读、只动这一次请求的 options:库没就绪 / 没 query / 检索抛错 / 没结果 → 原样返回。
717
+ * 不写事件、不落库、不进对话;注入次数只记 state.recallInjected。
718
+ */
719
+ function injectRecall(baseOptions, query) {
720
+ if (!query || !store || !state.storeReady) return baseOptions
721
+ if (typeof searchMemory !== 'function') return baseOptions
722
+ let found = null
723
+ try {
724
+ found = searchMemory(store, { query, k: RECALL_K, budget: RECALL_MAX_CHARS })
725
+ } catch {
726
+ // 检索失败:不注入、不报错、不影响流。
727
+ return baseOptions
728
+ }
729
+ let block = ''
730
+ try {
731
+ block = recallBlockFrom(found?.items)
732
+ } catch {
733
+ return baseOptions
734
+ }
735
+ if (!block) return baseOptions
736
+ state.recallInjected++
737
+ return withSystemLine(baseOptions, block)
738
+ }
739
+
665
740
  /**
666
741
  * 包住宿主拿到的流,把文本增量攒成一句,流结束(finally)时写一条 utterance。
667
742
  * - 只有 AsyncIterable 才包;其它返回值原样交回,不改变 next() 的语义;
@@ -769,10 +844,12 @@ export function attachToAgents(ctx, {
769
844
  const userText = lastUserText(options)
770
845
  // 聊天落库:只有 her 才记。会话 key 优先取 options;宿主没给就退回第一条
771
846
  // 挂上的 agent 的 sessionId/cwd。
847
+ let heardBody = ''
772
848
  if (her) {
773
849
  sessionKey = sessionKeyOf(options) || agentSessionKey
774
850
  // 往前找最多 4 条 user 消息:最后一条常常是宿主注入块,他的话在它前面。
775
- recordHeard(recentUserText(options), sessionKey)
851
+ heardBody = recentUserText(options)
852
+ recordHeard(heardBody, sessionKey)
776
853
  }
777
854
  if (typeof scanGuard === 'function') {
778
855
  let verdict = null
@@ -806,6 +883,10 @@ export function attachToAgents(ctx, {
806
883
  nextOptions = withSystemLine(nextOptions, DEV_PROMPT)
807
884
  }
808
885
  }
886
+
887
+ // 此刻想起:排在 guard、dev 之后(先规矩,再身份,最后是记忆)。
888
+ // 检索用的是上面清洗过的他那句话;失败/没结果都不动 system。
889
+ if (her) nextOptions = injectRecall(nextOptions, heardBody)
809
890
  } catch (err) {
810
891
  log(`提示词桥:llm/stream 观察失败(${errText(err)})`)
811
892
  }
package/lib/console.js CHANGED
@@ -1097,6 +1097,8 @@ function bridgeRoute(deps) {
1097
1097
  agentsNotHer: pick('agentsNotHer'),
1098
1098
  guardHits: pick('guardHits'),
1099
1099
  devHits: pick('devHits'),
1100
+ // 「此刻想起」注入过几次;只给次数,不给检索内容。
1101
+ recallInjected: pick('recallInjected'),
1100
1102
  stream: pick('stream'),
1101
1103
  // 聊天落库诊断:只给次数/形状,不给正文。
1102
1104
  // { streamsWrapped, chunksSeen, textParts, utterancesWritten, lastChunkShape }
package/lib/nightly.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // - 任何一步抛错都只记进 `steps.x.error`,后面的照跑;跑完(非 dryRun)照常记"今天整理过"。
8
8
  // - 状态记在旧库同一处:index.db 的 modes 表,`nightly.last`(毫秒)和 `nightly.<YYYY-MM-DD>`('1')。
9
9
  import { attributePending } from './reflect.js'
10
- import { createMind } from './split.js'
10
+ import { createMind, userDomOf } from './split.js'
11
11
  import { syncQuestions } from './questions.js'
12
12
  import { dream } from './dream.js'
13
13
  import { verifyDreams } from './verify.js'
@@ -147,9 +147,13 @@ export function createNightly({ store, log = () => {}, config = {}, model = null
147
147
  steps.mind = { ok: true, dryRun: true, folded: rows.length, ...m.describe() }
148
148
  } else {
149
149
  const m = mind && typeof mind.fold === 'function' ? mind : createMind({ store, log, config: cfg })
150
+ // 归因表里可能已经有几十条历史:按 seq 取"到这一条为止"的最近 20 条,算出 userDom 再传进去。
151
+ // 不指望缓存里的 recentLabels(老缓存没有),否则升级后要等 20 条新归因才判得出主导度。
152
+ const history = [...(store.attribAll?.() || [])].sort((a, b) => Number(a.seq) - Number(b.seq))
150
153
  let folded = 0
151
154
  for (const row of rows) {
152
- m.fold(row, { now: at })
155
+ const upto = history.filter((r) => Number(r.seq) <= Number(row.seq))
156
+ m.fold(row, { now: at, userDom: userDomOf(upto) })
153
157
  folded += 1
154
158
  }
155
159
  steps.mind = { ok: true, folded, ...m.describe() }
package/lib/split.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // 分流(M2):开局一条线;够了才分;分完才谈谁主导;主导会变,变要带证据。
2
2
  // 状态形状沿用旧实现的 modes.state:
3
3
  // {version,w:{d,i},n,counts:{d,i},dominance,split:{at,ev,n,share,tension[]},order,shifts[],
4
- // userDom:{d,i},lastDivergenceN,mimicryRisk,lastIds[],updatedAt}
4
+ // userDom:number|null,recentLabels[],lastDivergenceN,mimicryRisk,lastIds[],updatedAt}
5
+ // userDom 是"最近 N 条归因里 d 占已判归因的比例",没得判时是 null(不是 0)。
5
6
  // 这是可重建的小状态,不是历史:丢了可以从事件重放。
6
7
  import { clip } from './util.js'
7
8
 
@@ -15,7 +16,8 @@ export const ZERO_STATE = () => ({
15
16
  split: null,
16
17
  order: 'E=R',
17
18
  shifts: [],
18
- userDom: { d: 0, i: 0 },
19
+ userDom: null,
20
+ recentLabels: [],
19
21
  lastDivergenceN: null,
20
22
  mimicryRisk: false,
21
23
  lastIds: [],
@@ -33,6 +35,8 @@ export const DEFAULT_MIND = {
33
35
  shiftCooldownDays: 30,
34
36
  shiftRefs: 2,
35
37
  mimicryN: 20,
38
+ // 用户主导度离 0.5 小于这个值,才算"看不出他偏哪边"——模仿风险的前提。
39
+ mimicryBand: 0.15,
36
40
  }
37
41
 
38
42
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
@@ -50,6 +54,24 @@ export function orderOf(band) {
50
54
  return 'E=R'
51
55
  }
52
56
 
57
+ /**
58
+ * 用户主导度:最近 n 条归因里 d 占已判归因(d + i)的比例。
59
+ * - 窗口按"最近 n 条归因"取,null(纯描述)占窗口位置但不进分母;
60
+ * - 窗口里一条 d/i 都没有 → null(还没得判,不是 0)。
61
+ * @param {Array<{label?:string}|string>} rows 归因行(或裸 label)
62
+ * @param {{n?:number}} [opts]
63
+ * @returns {number|null} 0–1
64
+ */
65
+ export function userDomOf(rows = [], { n = DEFAULT_MIND.mimicryN } = {}) {
66
+ const limit = Math.max(1, Number(n) || DEFAULT_MIND.mimicryN)
67
+ const window = (Array.isArray(rows) ? rows : [])
68
+ .map((r) => (typeof r === 'string' ? r : r?.label))
69
+ .slice(-limit)
70
+ const judged = window.filter((l) => l === 'd' || l === 'i')
71
+ if (!judged.length) return null
72
+ return round(judged.filter((l) => l === 'd').length / judged.length, 3)
73
+ }
74
+
53
75
  /** 口吻:只给说法,不给数字。 */
54
76
  export function voiceLines(state) {
55
77
  const lines = []
@@ -75,7 +97,19 @@ export function createMind({ store, log = () => {}, config = {}, startDominance
75
97
  const raw = store?.modeGet?.(KEY)
76
98
  if (raw) {
77
99
  const j = JSON.parse(raw)
78
- if (j && Number(j.version) === 1) state = { ...ZERO_STATE(), ...j, w: { ...j.w }, counts: { ...j.counts }, userDom: { ...j.userDom } }
100
+ if (j && Number(j.version) === 1) {
101
+ state = {
102
+ ...ZERO_STATE(),
103
+ ...j,
104
+ w: { ...j.w },
105
+ counts: { ...j.counts },
106
+ // 老缓存里 userDom 是没喂过数据的 {d,i}:不认,归为 null,等下一次 fold 重算。
107
+ userDom: typeof j.userDom === 'number' && Number.isFinite(j.userDom) ? round(clamp(j.userDom, 0, 1), 3) : null,
108
+ recentLabels: Array.isArray(j.recentLabels)
109
+ ? j.recentLabels.map(String).filter((l) => l === 'd' || l === 'i' || l === 'null').slice(-cfg.mimicryN)
110
+ : [],
111
+ }
112
+ }
79
113
  }
80
114
  } catch (e) {
81
115
  log?.('读分流缓存失败,从头来:', e?.message || e)
@@ -91,9 +125,10 @@ export function createMind({ store, log = () => {}, config = {}, startDominance
91
125
 
92
126
  /**
93
127
  * 折一条归因。row: {seq, day, label:'d'|'i'|'null', conf, why, by, model, at, id?, text?}
128
+ * opts.userDom:调用方已经算好的"最近 N 条归因的 d 占比"(number|null);不给就用内部窗口。
94
129
  * 返回 {changed, split?:{...}, bandChange?:{from,to}}。
95
130
  */
96
- function fold(row = {}, { now = Date.now(), refs = [] } = {}) {
131
+ function fold(row = {}, { now = Date.now(), refs = [], userDom: givenUserDom } = {}) {
97
132
  const label = row.label === 'd' || row.label === 'i' ? row.label : null
98
133
  const before = { band: bandOf(state.dominance), order: state.order }
99
134
  state.n += 1
@@ -112,6 +147,17 @@ export function createMind({ store, log = () => {}, config = {}, startDominance
112
147
  state.lastIds = state.lastIds.slice(-4)
113
148
  }
114
149
 
150
+ // 用户主导度:最近 N 条归因里 d 的占比。null 归因占窗口位置,但不进分母。
151
+ // 老缓存没有 recentLabels,就从这一条开始重新攒——宁可先判"看不出来",也不要拿全局比例冒充。
152
+ if (!Array.isArray(state.recentLabels)) state.recentLabels = []
153
+ state.recentLabels.push(label || 'null')
154
+ state.recentLabels = state.recentLabels.slice(-cfg.mimicryN)
155
+ state.userDom = givenUserDom === undefined
156
+ ? userDomOf(state.recentLabels, { n: cfg.mimicryN })
157
+ : (givenUserDom == null || !Number.isFinite(Number(givenUserDom))
158
+ ? null
159
+ : round(clamp(Number(givenUserDom), 0, 1), 3))
160
+
115
161
  // 分流:四条都满足才写,写一次就不回头。
116
162
  let splitNow = null
117
163
  if (!state.split && state.n >= cfg.splitN && state.counts.d >= cfg.sideMin && state.counts.i >= cfg.sideMin) {
@@ -141,9 +187,12 @@ export function createMind({ store, log = () => {}, config = {}, startDominance
141
187
  }
142
188
  state.order = orderOf(band)
143
189
 
144
- // 模仿风险:连续很多条归因都没有"分歧"证据。
190
+ // 模仿风险:只有"连续很多条归因都没有分歧证据"、且"看不出他偏哪边"(userDom 在 0.5 附近)才亮。
191
+ // 没有 userDom(还没得判)一律不亮:不拿 null 当 0,否则 i 多的鱼会在第 20 条后恒为 true。
145
192
  if (String(row.why || '').includes('divergence')) state.lastDivergenceN = state.n
146
- state.mimicryRisk = state.n - (state.lastDivergenceN ?? 0) >= cfg.mimicryN
193
+ const noDivergence = state.n - (state.lastDivergenceN ?? 0) >= cfg.mimicryN
194
+ const nearHalf = state.userDom != null && Math.abs(state.userDom - 0.5) < cfg.mimicryBand
195
+ state.mimicryRisk = noDivergence && nearHalf
147
196
 
148
197
  save(now)
149
198
  return { changed: true, split: splitNow, bandChange }
@@ -170,6 +219,7 @@ export function createMind({ store, log = () => {}, config = {}, startDominance
170
219
  splitShare: state.split?.share ?? null,
171
220
  tension: state.split?.tension ?? [],
172
221
  shifts: state.shifts.length,
222
+ userDom: state.userDom,
173
223
  mimicryRisk: state.mimicryRisk,
174
224
  promptLines: voiceLines(state),
175
225
  updatedAt: state.updatedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pisces",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "鱼肥生双:一只会长大的本地小鱼(DSH 插件)。事件流记忆、渐进揭开、好感、新手引导;派希丝是养成目标。",
6
6
  "type": "module",