@ssamurias/dsh-fish 0.8.9 → 0.9.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 +64 -10
- package/CHANGELOG.md +129 -2
- package/LICENSE +7 -0
- package/README.md +69 -17
- package/RELEASE-NOTES-0.8.0.md +87 -0
- package/RELEASE-NOTES-0.8.1.md +93 -0
- package/RELEASE-NOTES-0.8.2.md +57 -0
- package/RELEASE-NOTES-0.8.3.md +71 -0
- package/RELEASE-NOTES-0.8.4.md +47 -0
- package/RELEASE-NOTES-0.8.5.md +244 -0
- package/RELEASE-NOTES-0.8.6.md +224 -0
- package/RELEASE-NOTES-0.8.7.md +202 -0
- package/RELEASE-NOTES-0.8.8.md +224 -0
- package/RELEASE-NOTES-0.9.0.md +267 -0
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE.md +151 -0
- package/THIRD-PARTY-NOTICES.md +3 -0
- package/lib/ask.js +18 -5
- package/lib/away.js +7 -2
- package/lib/browse.js +1 -0
- package/lib/capture.js +16 -1
- package/lib/channels.js +53 -13
- package/lib/client.js +145 -56
- package/lib/comment.js +74 -0
- package/lib/compact.js +71 -2
- package/lib/curiosity.js +6 -2
- package/lib/derive.js +21 -8
- package/lib/desk.js +4 -2
- package/lib/dream.js +3 -0
- package/lib/dreamtable.js +28 -2
- package/lib/drive.js +58 -9
- package/lib/freewake.js +21 -2
- package/lib/home.js +141 -24
- package/lib/inbox.js +7 -2
- package/lib/index.js +8 -12
- package/lib/interest.js +26 -4
- package/lib/log.js +22 -1
- package/lib/look.js +25 -5
- package/lib/note.js +35 -13
- package/lib/notify.js +2 -0
- package/lib/observe.js +22 -7
- package/lib/outing.js +2 -0
- package/lib/owner.js +5 -2
- package/lib/persona.js +18 -3
- package/lib/proactive.js +78 -18
- package/lib/say.js +104 -58
- package/lib/scheduler.js +162 -60
- package/lib/selfcheck.js +2 -3
- package/lib/selfdoc.js +8 -9
- package/lib/selfupdate.js +183 -41
- package/lib/settings.js +33 -2
- package/lib/status.js +31 -3
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +46 -4
- package/lib/update.js +14 -0
- package/lib/upgrade.js +23 -4
- package/lib/voices.js +2 -0
- package/package.json +5 -2
- package/scripts/fix-legacy-link.mjs +254 -0
package/lib/dreamtable.js
CHANGED
|
@@ -13,10 +13,14 @@
|
|
|
13
13
|
* 三条边界(沿用 dream.js 的四条硬约束):
|
|
14
14
|
* - 只用她自己说过的话(utterance / revise / reunion / narration / refusal),
|
|
15
15
|
* 别人的话、系统记录一律不进;refs 的合法性因此天然满足。
|
|
16
|
+
* 只留证的也不进(isProofOnly):没认领的聊天行不是「写下的」,
|
|
17
|
+
* 想改自己的话不算她的判断(derive.js 同样不算信念)。
|
|
16
18
|
* - 出门/请神那几趟写下的事件不摆(与 writeDream 的拒绝规则同源)。
|
|
17
19
|
* - 只陈述事实,不给台词、不派活:不出现「梦」「记一句」「你可以」。
|
|
18
20
|
*/
|
|
19
21
|
|
|
22
|
+
import { isProofOnly } from './derive.js'
|
|
23
|
+
|
|
20
24
|
/** 一句话要淡多久才值得被摆出来。太新的还热着,太旧的可能她已经不认了。 */
|
|
21
25
|
export const STALE_MIN_DAYS = 7
|
|
22
26
|
export const STALE_MAX_DAYS = 30
|
|
@@ -65,6 +69,7 @@ export function staleLines(events = [], opts = {}) {
|
|
|
65
69
|
const rows = []
|
|
66
70
|
for (const e of events) {
|
|
67
71
|
if (!HER_WORDS.has(e?.type)) continue
|
|
72
|
+
if (isProofOnly(e)) continue
|
|
68
73
|
if (trips.has(String(e?.sessionId ?? ''))) continue
|
|
69
74
|
const t = Date.parse(e?.ts ?? '')
|
|
70
75
|
if (!Number.isFinite(t)) continue
|
|
@@ -107,12 +112,18 @@ export function clipLine(text, max = LINE_CLIP) {
|
|
|
107
112
|
export function renderTable(rows = [], { gapHours = 0 } = {}) {
|
|
108
113
|
const list = (Array.isArray(rows) ? rows : []).filter((r) => r && r.id && String(r.text ?? '').trim())
|
|
109
114
|
if (list.length < TABLE_MIN) return ''
|
|
110
|
-
|
|
115
|
+
// 2026-11-06(issue #42):原来写「这次隔了 N 个小时」,读起来像「她睡了 N 小时」。
|
|
116
|
+
// hoursSinceLastWake 只数 `wake:`,中间她可能一直在跟人说话、开过好几轮。
|
|
117
|
+
// 改成只说它真算的那件事:距上一次醒来多久。
|
|
118
|
+
const head = gapHours >= 1 ? `距你上次醒来,隔了 ${Math.round(gapHours)} 个小时。` : ''
|
|
111
119
|
const lines = list.slice(0, TABLE_MAX).map((r) => {
|
|
112
120
|
const when = shortDay(r.ts)
|
|
113
121
|
return `- 「${clipLine(r.text)}」${when ? ` · ${when}` : ''} · ${r.id}`
|
|
114
122
|
})
|
|
115
|
-
|
|
123
|
+
// 2026-11-06(issue #42):原来后半句写「这些天再没被提起过」。判据其实只是
|
|
124
|
+
// 「之后有没有事件 refs 指回它」——她闲聊时提过一句但没有 refs,就仍然会被说成「没提起过」。
|
|
125
|
+
// 这句是给她读的,不该撒谎,所以把那半句去掉,只陈述摆在那儿这件事。
|
|
126
|
+
return [head, '你写下的几行字还摊在那儿:', '', ...lines]
|
|
116
127
|
.filter((x) => x !== '')
|
|
117
128
|
.join('\n')
|
|
118
129
|
}
|
|
@@ -135,6 +146,21 @@ export function tableFor(opts = {}) {
|
|
|
135
146
|
return renderTable(opts.rows ?? [], { gapHours: gap })
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
/**
|
|
150
|
+
* 旧句子在空醒那一轮只摆一处(2026-11-05 真机 18:44 那次)。
|
|
151
|
+
*
|
|
152
|
+
* 摆料已经由 tableFor 渲染成「这次隔了 N 个小时。你写下的几行字还摊在那儿…」,
|
|
153
|
+
* 同一批行又被 deskFor 当 stale 摆进「桌上摊着这几样东西:」——她那一轮读到的是
|
|
154
|
+
* 同三句原话出现两遍,像系统在盘货。所以 tableText 非空时,桌子不再收 stale。
|
|
155
|
+
*
|
|
156
|
+
* @param {string} tableText
|
|
157
|
+
* @param {Array} rows staleLines() 的结果
|
|
158
|
+
* @returns {Array}
|
|
159
|
+
*/
|
|
160
|
+
export function staleForDesk(tableText, rows = []) {
|
|
161
|
+
return String(tableText ?? '').trim() ? [] : rows
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
/** 上一次叫醒到现在隔了多久(小时)。没有记录就返回 0。 */
|
|
139
165
|
export function hoursSinceLastWake(events = [], now = Date.now()) {
|
|
140
166
|
let last = 0
|
package/lib/drive.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* drive.js —— 「劲儿」:她什么时候攒够了想动一动(2026-11-05,用户 m02031)
|
|
3
3
|
*
|
|
4
|
-
* 由来:江屿那条线的做法是「攒一个 0..1 的数,到 0.8
|
|
4
|
+
* 由来:江屿那条线的做法是「攒一个 0..1 的数,到 0.8 想动一次,动完扣一点」。
|
|
5
5
|
* 用户认可这个手感,但要它**从事件流算出来**,不另存一份 `drive.json`:
|
|
6
6
|
* 落盘的状态会跟事件流各说各话,而她是谁只该由事件流决定。
|
|
7
7
|
*
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
export const DRIVE = Object.freeze({
|
|
22
22
|
growthPerHour: 0.12,
|
|
23
23
|
threshold: 0.8,
|
|
24
|
-
cost: 0.
|
|
25
|
-
minGapMs:
|
|
24
|
+
cost: 0.8,
|
|
25
|
+
minGapMs: 2 * 60 * 60_000,
|
|
26
26
|
/** 桌上每一样没合上的事 / 每一行旧句子各加多少(各最多算两份 / 三份)。 */
|
|
27
27
|
boostGap: 0.06,
|
|
28
28
|
boostStale: 0.04,
|
|
@@ -42,8 +42,13 @@ const HOUR = 3_600_000
|
|
|
42
42
|
const DAY = 86_400_000
|
|
43
43
|
|
|
44
44
|
function tsOf(e) {
|
|
45
|
-
const
|
|
46
|
-
|
|
45
|
+
const raw = e?.ts ?? e?.time ?? e?.at
|
|
46
|
+
// 事件里的 `ts` 是 ISO 字符串(不是毫秒数):先按数字试,再按日期解析。
|
|
47
|
+
// 2026-11-05 真机:只写 `Number(raw)` 时字符串一律 NaN,出生时刻与「上次动过」
|
|
48
|
+
// 全被算成 0,劲儿永远 0.00(`还没有任何记录,不急着动`),修了锚点也没用。
|
|
49
|
+
if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : 0
|
|
50
|
+
const asDate = Date.parse(String(raw ?? ''))
|
|
51
|
+
if (Number.isFinite(asDate) && asDate > 0) return asDate
|
|
47
52
|
const d = Date.parse(String(e?.iso ?? e?.date ?? ''))
|
|
48
53
|
return Number.isFinite(d) ? d : 0
|
|
49
54
|
}
|
|
@@ -73,6 +78,34 @@ function lastHeardAt(events) {
|
|
|
73
78
|
return last
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 最早一条事件的时刻(她开始存在的那一刻)。
|
|
83
|
+
*
|
|
84
|
+
* 为什么要它:只认 `drive-spend:` / `proactive-turn:` / `proactive-silent:`
|
|
85
|
+
* 时,一个从没主动过的她会永远算成 0.00——那三类事件本来就要「先动一次」
|
|
86
|
+
* 才会写,于是第一动永远等不到(2026-11-05 真机 `drive 0.00 · 还没有任何记录`)。
|
|
87
|
+
* 出生就是起跑线:从她存在那一刻起,时间就在攒劲儿。
|
|
88
|
+
*/
|
|
89
|
+
function firstAt(events) {
|
|
90
|
+
let first = 0
|
|
91
|
+
for (const e of events) {
|
|
92
|
+
const ts = tsOf(e)
|
|
93
|
+
if (ts > 0 && (!first || ts < first)) first = ts
|
|
94
|
+
}
|
|
95
|
+
return first
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 他最近一次来过(`meta seen:`)的时刻。 */
|
|
99
|
+
function lastSeenLine(events) {
|
|
100
|
+
let last = 0
|
|
101
|
+
for (const e of events) {
|
|
102
|
+
if (e?.type !== 'meta' || !String(e.text ?? '').startsWith('seen:')) continue
|
|
103
|
+
const ts = tsOf(e)
|
|
104
|
+
if (ts > last) last = ts
|
|
105
|
+
}
|
|
106
|
+
return last
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
/** 她最近一次真的开口的时刻。 */
|
|
77
110
|
function lastSpokeAt(events) {
|
|
78
111
|
let last = 0
|
|
@@ -98,20 +131,22 @@ function lastSpokeAt(events) {
|
|
|
98
131
|
* @returns {{value:number, threshold:number, ready:boolean, why:string,
|
|
99
132
|
* hoursSince:number, boosts:number, damp:number}}
|
|
100
133
|
*/
|
|
101
|
-
export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1 } = {}) {
|
|
134
|
+
export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1, seenCountsAsHeard = false } = {}) {
|
|
102
135
|
const base = { value: 0, threshold: DRIVE.threshold, ready: false, why: '', hoursSince: 0, boosts: 0, damp: 1, speed: DRIVE.growthPerHour }
|
|
103
|
-
|
|
136
|
+
// 动过就从那次算;一次都没动过,就从她出生那一刻算(不能永远停在 0.00)。
|
|
137
|
+
const last = lastActionAt(events) || firstAt(events)
|
|
104
138
|
if (!last) return { ...base, why: '还没有任何记录,不急着动' }
|
|
105
139
|
|
|
106
140
|
const hoursSince = Math.max(0, (now - last) / HOUR)
|
|
107
141
|
// 刚动过:哪怕攒得快,也得隔开一点——两次冲动贴在一起就不像冲动了。
|
|
108
142
|
if (now - last < DRIVE.minGapMs) {
|
|
109
|
-
return { ...base, hoursSince, why: '
|
|
143
|
+
return { ...base, hoursSince, why: '刚动过,还没到两小时' }
|
|
110
144
|
}
|
|
111
145
|
|
|
112
146
|
// 上次开口没人接:底噪打折(不是惩罚,只是「说了也没人接」这件事本身的重量)。
|
|
113
147
|
const spoke = lastSpokeAt(events)
|
|
114
|
-
|
|
148
|
+
// 对话不进事件流的部署(proactive.idleCountsSeen):他来过就算接了,否则这个折扣永远解不掉
|
|
149
|
+
const heard = Math.max(lastHeardAt(events), seenCountsAsHeard ? lastSeenLine(events) : 0)
|
|
115
150
|
const damp = spoke && heard < spoke ? DRIVE.dampUnanswered : 1
|
|
116
151
|
|
|
117
152
|
const gaps = Math.max(0, Number(desk?.gaps) || 0)
|
|
@@ -140,6 +175,20 @@ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1
|
|
|
140
175
|
}
|
|
141
176
|
}
|
|
142
177
|
|
|
178
|
+
/**
|
|
179
|
+
* 桌上有没有可说的东西(2026-11-05,用户 m02937)。
|
|
180
|
+
*
|
|
181
|
+
* 劲儿只回答「攒够了没有」,不回答「攒够了有没有事可说」。空桌醒来她只能回一句
|
|
182
|
+
* 「没什么要说的」——那既占额度,又在对话里留一条噪音。所以材料也是判据,不是装饰。
|
|
183
|
+
*
|
|
184
|
+
* @param {{gaps?:number, stale?:number, echo?:boolean, favorite?:boolean}|null} desk
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function deskHasMaterial(desk) {
|
|
188
|
+
if (!desk) return false
|
|
189
|
+
return Number(desk.gaps) > 0 || Number(desk.stale) > 0 || desk.echo === true || desk.favorite === true
|
|
190
|
+
}
|
|
191
|
+
|
|
143
192
|
/** 一次行动的代价(动完扣掉)。留给记账用,纯数值。 */
|
|
144
193
|
export function driveCost() {
|
|
145
194
|
return DRIVE.cost
|
package/lib/freewake.js
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* - table:旧句子要摆出来的那一次(lib/dreamtable.js 的三道门已经判过)。
|
|
14
14
|
* - think:**兜底**(2026-11-05,用户 m02008 拍板)。原来按「每 3 天一次」的日历开,
|
|
15
15
|
* 现在改成:过去 `QUIET_DAYS` 天里她一件自发的事都没有(没有 utterance /
|
|
16
|
-
* narration / dream /
|
|
16
|
+
* narration / dream / 主动开口;聊天回合补写的 uncommitted 行不算),才由日历补一次。劲儿正常工作时不该再出现
|
|
17
17
|
* 「同一天醒两次」,所以这一条只在劲儿长期趴着时生效。
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* 0.9.0 起公开:所有机器都能空醒;想关掉把 config.memory.thinkEveryDays 设 0。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { tsMs, HER_TYPES } from './schema.js'
|
|
@@ -45,6 +45,9 @@ export function quietDays(events = [], now = Date.now()) {
|
|
|
45
45
|
continue
|
|
46
46
|
}
|
|
47
47
|
if (!HER_TYPES.includes(e?.type)) continue
|
|
48
|
+
// 聊天回合里的回复不是她自己动的,否则每天有人聊,这条兜底就永远等不到。
|
|
49
|
+
// selfmod 不跳:想改自己也是她自己动过,这里问的不是算不算信念。
|
|
50
|
+
if (Array.isArray(e.flags) && e.flags.includes('uncommitted')) continue
|
|
48
51
|
const ts = tsMs(e)
|
|
49
52
|
if (Number.isFinite(ts) && ts > last) last = ts
|
|
50
53
|
}
|
|
@@ -67,6 +70,8 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
|
|
|
67
70
|
// 摆料优先:材料在那儿,这一轮就该只有材料。
|
|
68
71
|
if (String(tableText ?? '').trim()) return { kind: 'table', note: FREE_NOTE }
|
|
69
72
|
|
|
73
|
+
// 0.9.0:空醒有一个设置页开关(memory.selfWake)。关掉就只剩摆料,不再自己找事做。
|
|
74
|
+
if (config?.memory?.selfWake === false) return null
|
|
70
75
|
const every = Number(config?.memory?.thinkEveryDays ?? THINK_EVERY_DAYS)
|
|
71
76
|
if (!(every > 0)) return null
|
|
72
77
|
|
|
@@ -80,3 +85,17 @@ export function freeWakeFor({ tableText = '', events = [], dk = '', now = Date.n
|
|
|
80
85
|
|
|
81
86
|
return { kind: 'think', note: FREE_NOTE }
|
|
82
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 这一轮还要不要摆世界动静与惦记清单。
|
|
91
|
+
*
|
|
92
|
+
* 2026-11-05 用户 m01128:table 那一轮连 B 站和桌子都去掉,只留便条、时间、
|
|
93
|
+
* 旧句子和「这一轮没有待办」——工具常量那一句留着,那不是活,是她世界里的常量。
|
|
94
|
+
* think 那一轮照旧:它本来就是「隔些日子空一次」,没有材料可给,世界动静是唯一的东西。
|
|
95
|
+
*
|
|
96
|
+
* @param {{kind:string}|null} free
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function keepWorldOnWake(free) {
|
|
100
|
+
return free?.kind !== 'table'
|
|
101
|
+
}
|
package/lib/home.js
CHANGED
|
@@ -197,8 +197,16 @@ export async function ensureTalkSession(ctx, opts = {}) {
|
|
|
197
197
|
* 现在先把「归档的那条」和「这个工作区里另外的活会话」分开判断:只要还有一条
|
|
198
198
|
* 没归档的,就认它,而不是让整个家停摆。
|
|
199
199
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
200
|
+
* 取最前面那条(宿主建会话时是 `sessionIds: [新id, ...旧的]`,往**前**插,
|
|
201
|
+
* 所以头部才是最新那条),但不做别的假设:拿不到 registry、sessionIds 不是数组、
|
|
202
|
+
* 全是归档的,都退回空串,由调用方按原样拒绝。
|
|
203
|
+
*
|
|
204
|
+
* issue #33:这里以前取 `live[live.length - 1]`,注释还写着「宿主按创建顺序往
|
|
205
|
+
* sessionIds 里追加」——方向反了。结果是当前会话被归档、同一工作区还有别的活会话时,
|
|
206
|
+
* 认回来的是**最旧**那条,她于是坐进一条早就没在用的对话。
|
|
207
|
+
* 需要提醒的是:用户在侧栏拖动过顺序时 `live[0]` 是列表最上面那条,未必就是最新;
|
|
208
|
+
* 真要按时间严格排序,得问 `sessionController.list()`(`sessionListed` 那条路)。
|
|
209
|
+
* 这里只修「明显反了」的方向,不对侧栏顺序做额外假设。
|
|
202
210
|
*/
|
|
203
211
|
function pickLiveSession(reg, wsId, avoid = '') {
|
|
204
212
|
try {
|
|
@@ -208,7 +216,7 @@ function pickLiveSession(reg, wsId, avoid = '') {
|
|
|
208
216
|
const live = ids
|
|
209
217
|
.map((id) => String(id ?? ''))
|
|
210
218
|
.filter((id) => id && id !== avoid && !archived.includes(id))
|
|
211
|
-
return live.length ? live[
|
|
219
|
+
return live.length ? live[0] : ''
|
|
212
220
|
} catch { return '' }
|
|
213
221
|
}
|
|
214
222
|
|
|
@@ -684,6 +692,35 @@ export const LAST_WORDS_WAIT_MS = 60_000
|
|
|
684
692
|
*/
|
|
685
693
|
export const WAKE_VERIFY_MS = 12_000
|
|
686
694
|
|
|
695
|
+
/**
|
|
696
|
+
* 换会话失败之后多久再试(2026-11-06)。
|
|
697
|
+
*
|
|
698
|
+
* 结账那条路上每次人说话都会 bumpTurns,`turns` 到了线就试着换。换失败时
|
|
699
|
+
* `turns` 不归零(这是对的:屋子确实还没换),于是下一轮又会试——宿主真
|
|
700
|
+
* 建不出会话时就成了每回一句话都重试一次。这里记下失败的时刻,冷却期内
|
|
701
|
+
* 直接跳过,不再打扰宿主,也不再往对话里发东西。
|
|
702
|
+
*/
|
|
703
|
+
export const ROTATE_RETRY_MS = 5 * 60_000
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* 记一笔「这次没换成」。只写 home.json 的两个字段,任何失败都吞掉:
|
|
707
|
+
* 记不下来顶多是下次不回退避,不能让记账本身把错误放大。
|
|
708
|
+
*/
|
|
709
|
+
async function noteRotateFail(root, why) {
|
|
710
|
+
const at = new Date().toISOString()
|
|
711
|
+
try {
|
|
712
|
+
await mutateHome(async () => {
|
|
713
|
+
const fresh = (await readJson(homeFile(root), null)) ?? {}
|
|
714
|
+
await writeAtomic(
|
|
715
|
+
homeFile(root),
|
|
716
|
+
JSON.stringify({ ...fresh, rotateFailAt: at, rotateFailWhy: String(why ?? '').slice(0, 160) }, null, 2),
|
|
717
|
+
)
|
|
718
|
+
})
|
|
719
|
+
} catch {
|
|
720
|
+
/* 记不下来就算了 */
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
687
724
|
/** 她说「不用」的各种说法。命中就当没留。 */
|
|
688
725
|
const DECLINE_RE = /^(不用|不想留|不想|不留|不了|算了|没事|没有|无|no|nope)[。.!!…\s]*$/i
|
|
689
726
|
|
|
@@ -789,11 +826,56 @@ async function askForLastWords(ctx, { sessionId, root, log } = {}) {
|
|
|
789
826
|
return pickLastWords(say.join('\n'))
|
|
790
827
|
}
|
|
791
828
|
|
|
829
|
+
// 同一个 root 只允许有一次「换房间」在飞(0.8.8 用户报的房间分裂):
|
|
830
|
+
// `/say` 的结账与 turns 的结账是两条独立路径,都可能在满 rotateAfter 时触发。
|
|
831
|
+
// 没有互斥时各自 sc.create 一条新会话:侧栏多出一间同名空房,先建的那条
|
|
832
|
+
// 还可能已经跑了一轮,从此成了看不到的孤儿;两条入口也各记各的轮数。
|
|
833
|
+
// 进程内用这条 promise 链单飞;跨进程由 withFileLock + home.json 的 sessionId 比对兜住。
|
|
834
|
+
const rotateInflight = new Map()
|
|
835
|
+
|
|
792
836
|
/**
|
|
793
837
|
* 换一个新会话:旧的改名留档,新的接管,原话搬过去。
|
|
794
|
-
*
|
|
838
|
+
*
|
|
839
|
+
* 0.8.8:并发调用合并成一次——后来者直接等前面那次的结果,不再各建一条。
|
|
840
|
+
* `from` 是调用方这句话真正投递进去的那条会话 id;home.json 已经指向别的会话,
|
|
841
|
+
* 说明另一条入口抢先换过了(或者人已经不在家记着的这条里说话),这次跳过不换。
|
|
842
|
+
*
|
|
843
|
+
* @returns {Promise<{ok:boolean, sessionId:string, old?:string, why?:string, skipped?:boolean}>}
|
|
795
844
|
*/
|
|
796
|
-
export async function rotateTalkSession(ctx,
|
|
845
|
+
export async function rotateTalkSession(ctx, opts = {}) {
|
|
846
|
+
const root = opts.root ?? rootDir()
|
|
847
|
+
const key = String(root).toLowerCase()
|
|
848
|
+
const flying = rotateInflight.get(key)
|
|
849
|
+
if (flying) return flying
|
|
850
|
+
const p = (async () => {
|
|
851
|
+
try {
|
|
852
|
+
const res = await withFileLock(
|
|
853
|
+
sessionLockFile(root),
|
|
854
|
+
() => rotateTalkSessionOnce(ctx, { ...opts, root }),
|
|
855
|
+
opts.lockOpts,
|
|
856
|
+
)
|
|
857
|
+
if (res && res.locked === false) {
|
|
858
|
+
// 另一个进程正在换:它换完会把 sessionId 写进 home.json,这里读一眼就跳过。
|
|
859
|
+
const saved = await readJson(homeFile(root), null).catch(() => null)
|
|
860
|
+
const sid = String(saved?.sessionId ?? '')
|
|
861
|
+
const from = String(opts.from ?? '')
|
|
862
|
+
if (sid && (!from || sid !== from)) {
|
|
863
|
+
return { ok: true, sessionId: sid, old: String(saved?.rotatedFrom ?? ''), skipped: true, why: res.why }
|
|
864
|
+
}
|
|
865
|
+
return { ok: false, sessionId: '', why: res.why }
|
|
866
|
+
}
|
|
867
|
+
return res
|
|
868
|
+
} catch (err) {
|
|
869
|
+
return { ok: false, sessionId: '', why: `换会话的锁没拿到:${String(err?.message ?? err).slice(0, 120)}` }
|
|
870
|
+
}
|
|
871
|
+
})().finally(() => {
|
|
872
|
+
if (rotateInflight.get(key) === p) rotateInflight.delete(key)
|
|
873
|
+
})
|
|
874
|
+
rotateInflight.set(key, p)
|
|
875
|
+
return p
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITLE_SESSION, stamp, log, from = '' } = {}) {
|
|
797
879
|
const sc = ctx.sessionController
|
|
798
880
|
if (!sc?.create) return { ok: false, sessionId: '', why: '宿主没有会话服务' }
|
|
799
881
|
|
|
@@ -802,24 +884,72 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
|
|
|
802
884
|
if (!wid) return { ok: false, sessionId: '', why: '没有工作区 id' }
|
|
803
885
|
|
|
804
886
|
const old = String(saved.sessionId ?? '')
|
|
887
|
+
// 0.8.8:这条路径进来时屋子已经换过了(另一条入口抢先、或 concurrent 的
|
|
888
|
+
// 第二次调用在单飞窗口之外到)——不要再建第二条。
|
|
889
|
+
const fromId = String(from ?? '')
|
|
890
|
+
if (fromId && old && fromId !== old) {
|
|
891
|
+
log?.(`换会话跳过:这次要换的是 ${fromId},屋子已经指向 ${old}`)
|
|
892
|
+
return { ok: true, sessionId: old, old: String(saved.rotatedFrom ?? ''), skipped: true }
|
|
893
|
+
}
|
|
894
|
+
// 2026-11-06:建会话失败后冷却一段时间再试。
|
|
895
|
+
// 不冷却的话,结账每轮都会走到这里:`turns` 没清零,下一轮结账又到点,
|
|
896
|
+
// 于是每回一句话都重试一次(旧版还会每次都先问一遍「要不要留话」,见 2.5 步)。
|
|
897
|
+
// 宿主建不出会话多半是这类问题要待一会儿才好,5 分钟够它缓过来,也不至于
|
|
898
|
+
// 让屋子永远卡在旧会话里。
|
|
899
|
+
const failAt = Date.parse(String(saved.rotateFailAt ?? ''))
|
|
900
|
+
if (Number.isFinite(failAt) && Date.now() - failAt < ROTATE_RETRY_MS) {
|
|
901
|
+
return {
|
|
902
|
+
ok: false,
|
|
903
|
+
sessionId: '',
|
|
904
|
+
cooled: true,
|
|
905
|
+
why: `上次没换成(${String(saved.rotateFailWhy ?? '原因不明').slice(0, 80)}),${Math.ceil((ROTATE_RETRY_MS - (Date.now() - failAt)) / 60_000)} 分钟后再试`,
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
805
909
|
// 2026-11-03(issue #19):原来 `new Date().toISOString().slice(0,10)` 取的是 **UTC 日期**,
|
|
806
910
|
// 东八区晚上 8 点之后换会话,旧会话会被改名成「…·昨天的日期 之前」.
|
|
807
911
|
// dateKey() 用的是 getFullYear/getMonth/getDate,本地钟。
|
|
808
912
|
const when = stamp ?? dateKey()
|
|
809
913
|
|
|
914
|
+
// 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
|
|
915
|
+
if (old && typeof sc.rename === 'function') {
|
|
916
|
+
await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 2) 建新的
|
|
920
|
+
let sessionId = ''
|
|
921
|
+
try {
|
|
922
|
+
const created = await sc.create({ workspaceId: wid })
|
|
923
|
+
sessionId = String(created?.sessionId ?? created?.id ?? '')
|
|
924
|
+
} catch (err) {
|
|
925
|
+
await noteRotateFail(root, `建会话失败:${String(err?.message ?? err).slice(0, 160)}`)
|
|
926
|
+
return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
|
|
927
|
+
}
|
|
928
|
+
if (!sessionId) {
|
|
929
|
+
await noteRotateFail(root, '宿主建了会话但没给 id')
|
|
930
|
+
return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
|
|
931
|
+
}
|
|
932
|
+
if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
|
|
933
|
+
|
|
810
934
|
/**
|
|
811
|
-
*
|
|
935
|
+
* 2.5) 换房间之前,先请旧会话里的她自己留一句话(0.8.7,用户 m00998)。
|
|
812
936
|
*
|
|
813
937
|
* 为什么要这一句:轮换之后新会话拿到的身份文档里全是**结论**,没有一句是
|
|
814
938
|
* 「上一个房间里的我留下的」。于是她读到旧日记会说「像另一个人写的」。
|
|
815
939
|
* 一句原话解决的不是记忆问题(原文一直都在),是**归属**问题——
|
|
816
940
|
* 「那是你说的」这句话只有她本人说出来才算数。
|
|
817
941
|
*
|
|
942
|
+
* **为什么放在建会话成功之后**(2026-11-06 修):这句话是往她正在用的那条
|
|
943
|
+
* 会话里发一条 prompt,会作为一条消息直接出现在他的对话窗口里。原来写在
|
|
944
|
+
* 第 0 步(建会话之前),于是只要 `sc.create()` 抛错/不回 id,这一问就已经
|
|
945
|
+
* 发出去了、轮换却失败了,`turns` 也不清零——下一轮结账又到点、又问一遍,
|
|
946
|
+
* 他那边就变成「每次她回完都弹这一句」。挪到建会话成功之后,换不成就不问。
|
|
947
|
+
*
|
|
818
948
|
* **只把话发出去,不等她回**。轮换比这句话重要得多:
|
|
819
949
|
* 上一版是 `await askForLastWords(...)`,宿主不派 `turn/end` 时会一直等到
|
|
820
950
|
* 60 秒超时,把整个轮换(以及叫醒新会话)全卡住。所以这里只拿到一个
|
|
821
|
-
* Promise
|
|
822
|
-
*
|
|
951
|
+
* Promise,写 home 照常往下走;等这句话回来了再补写进 home.json 并刷一次
|
|
952
|
+
* 身份文档(她那一下的人设段是新房间里第一次读到的)。
|
|
823
953
|
*
|
|
824
954
|
* 三个约束:
|
|
825
955
|
* - 失败绝不影响轮换:没有 prompt / 超时 / 她说「不用」都只是没有这一节;
|
|
@@ -837,22 +967,6 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
|
|
|
837
967
|
pendingWords = null
|
|
838
968
|
}
|
|
839
969
|
|
|
840
|
-
// 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
|
|
841
|
-
if (old && typeof sc.rename === 'function') {
|
|
842
|
-
await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// 2) 建新的
|
|
846
|
-
let sessionId = ''
|
|
847
|
-
try {
|
|
848
|
-
const created = await sc.create({ workspaceId: wid })
|
|
849
|
-
sessionId = String(created?.sessionId ?? created?.id ?? '')
|
|
850
|
-
} catch (err) {
|
|
851
|
-
return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
|
|
852
|
-
}
|
|
853
|
-
if (!sessionId) return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
|
|
854
|
-
if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
|
|
855
|
-
|
|
856
970
|
// 3) 原话搬运
|
|
857
971
|
// 2026-10-31(issue #14 第二报):这里以前是**整个覆盖写**,只留七个字段,
|
|
858
972
|
// 于是 `preset` / `title` / `wokeFor` / `hearAt` / `adoptedAt` 换一次会话全丢:
|
|
@@ -880,6 +994,9 @@ export async function rotateTalkSession(ctx, { root = rootDir(), wsId, title = T
|
|
|
880
994
|
}
|
|
881
995
|
delete next.status
|
|
882
996
|
delete next.archivedAt
|
|
997
|
+
// 换成了,把上次失败的记账清掉(否则下一次到点时会被误判成还在冷却期)
|
|
998
|
+
delete next.rotateFailAt
|
|
999
|
+
delete next.rotateFailWhy
|
|
883
1000
|
// 0.8.7:这一轮先清掉旧的那句。她要是留了,后台回来之后再补写
|
|
884
1001
|
// (见下面 pendingWords.then)。留着上一轮那句会让新房间读到一句
|
|
885
1002
|
// 不是这次说的「最后一句」——比没有更糟。
|
package/lib/inbox.js
CHANGED
|
@@ -196,9 +196,14 @@ export async function writeInboxFile(root = rootDir(), { limit = 60 } = {}) {
|
|
|
196
196
|
return file
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* 事件流里已经存在的她自己写的话(供 say/wake 判断"这轮她开没开口")
|
|
201
|
+
*
|
|
202
|
+
* 不收 uncommitted:那是别处聊天回合结束时补写的,没认领,也不是这一轮说的。
|
|
203
|
+
* 群里 /say 最长等 75 秒,侧栏回合正好结束时,私聊原话会被当成她在群里的回复。
|
|
204
|
+
*/
|
|
200
205
|
export function newHerEventsSince(events, sinceId) {
|
|
201
206
|
const idx = sinceId ? events.findIndex((e) => e.id === sinceId) : events.length
|
|
202
207
|
const from = idx >= 0 ? idx + 1 : 0
|
|
203
|
-
return events.slice(from).filter((e) => SPEAK_TYPES.has(e.type))
|
|
208
|
+
return events.slice(from).filter((e) => SPEAK_TYPES.has(e.type) && !(Array.isArray(e.flags) && e.flags.includes('uncommitted')))
|
|
204
209
|
}
|
package/lib/index.js
CHANGED
|
@@ -25,7 +25,6 @@ import { registerBringBack } from './keep.js'
|
|
|
25
25
|
import { registerAsk } from './ask.js'
|
|
26
26
|
import { registerDream } from './dream.js'
|
|
27
27
|
import { registerProactive } from './proactive.js'
|
|
28
|
-
import { devModeSync } from './devmode.js'
|
|
29
28
|
import { registerObserve } from './observe.js'
|
|
30
29
|
import { startScheduler } from './scheduler.js'
|
|
31
30
|
import { registerTurns } from './turns.js'
|
|
@@ -47,7 +46,7 @@ import { logBoot, makeLogger } from './log.js'
|
|
|
47
46
|
export const name = 'dsh-fish'
|
|
48
47
|
|
|
49
48
|
/** 版本号:只用于日志导出(排查时第一件事是确认跑的是哪一版)。 */
|
|
50
|
-
export const VERSION = '0.
|
|
49
|
+
export const VERSION = '0.9.1'
|
|
51
50
|
|
|
52
51
|
// ⚠️ 一个数组写全,不要拆成对象
|
|
53
52
|
// 2026-10-30(m02961):`userQuestions` **不再写进 inject**。写进 inject 的东西缺一个,
|
|
@@ -223,10 +222,9 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
223
222
|
herDir,
|
|
224
223
|
extra: config?.tools?.allow ?? [],
|
|
225
224
|
enabled: config?.tools?.restrict !== false,
|
|
226
|
-
//
|
|
227
|
-
dream:
|
|
228
|
-
|
|
229
|
-
proactive: devModeSync(root),
|
|
225
|
+
// 0.9.0 起公开:梦与主动说话不再是本机实验。
|
|
226
|
+
dream: true,
|
|
227
|
+
proactive: true,
|
|
230
228
|
log: (...a) => logger.info(...a),
|
|
231
229
|
onResult: (r) => { restrictState = r },
|
|
232
230
|
})
|
|
@@ -355,14 +353,12 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
355
353
|
// 0.7.6(2026-10-30 m01274):请求事件。她有事想征得同意时弹一张卡,
|
|
356
354
|
// 每天 1 次、每周 3 次;只有 time / rest 真会改东西(见 lib/ask.js)。
|
|
357
355
|
() => registerAsk(ctx, { root, tenant: tenancy }),
|
|
358
|
-
// 0.8.1
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// 0.8.1 本机实验(2026-10-30 m04610):主动说话——她自己判断有没有话想说。
|
|
362
|
-
// 同样只在 .dev 下注册;公开版没有 her_say,也没有空手回合。
|
|
356
|
+
// 0.8.1 起(2026-10-30 m02837):梦 A 案——醒来记一句。0.9.0 起公开。
|
|
357
|
+
() => registerDream(ctx, { root, tenant: tenancy }),
|
|
358
|
+
// 0.8.1 起(2026-10-30 m04610):主动说话——她自己判断有没有话想说。0.9.0 起公开。
|
|
363
359
|
// 2026-10-30 真机:这里原来只有 { root, log },guardWrite 拿到 undefined 的 state,
|
|
364
360
|
// checkCaller(undefined) 每次都判「不在这个家」;写入守卫要的是 tenancy。
|
|
365
|
-
|
|
361
|
+
() => registerProactive(ctx, { root, tenant: tenancy, log: (...a) => logger.info(...a) }),
|
|
366
362
|
]) {
|
|
367
363
|
try {
|
|
368
364
|
const d = reg()
|
package/lib/interest.js
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 三条边界:
|
|
9
9
|
* 1. **只读她说过的话**(`HER_TYPES`),不去猜主人喜欢什么——兴趣是她的,不是画像。
|
|
10
|
+
* 只留证的不算(isProofOnly):「好的,我看一下」说十遍也不是她最近总回到的事。
|
|
10
11
|
* 2. **正文里没有分数、没有次数**。权重只决定摆哪一条(四条黑名单第三条)。
|
|
11
12
|
* 3. **会腻**:同一件事刚在 24 小时内出现过,这一次的分量打对折;
|
|
12
13
|
* 两周前的老话题按 14 天半衰——越久没提,越不像「最近总回到」。
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import { similarity } from './derive.js'
|
|
16
|
+
import { isProofOnly, similarity } from './derive.js'
|
|
16
17
|
import { tsMs, HER_TYPES } from './schema.js'
|
|
17
18
|
|
|
18
19
|
/** 至少在几个不同时刻出现过,才算「反复」。 */
|
|
@@ -52,6 +53,7 @@ export function topicsOf(events = []) {
|
|
|
52
53
|
const out = []
|
|
53
54
|
for (const e of events) {
|
|
54
55
|
if (!HER_TYPES.includes(e?.type)) continue
|
|
56
|
+
if (isProofOnly(e)) continue
|
|
55
57
|
const ts = tsMs(e)
|
|
56
58
|
if (!Number.isFinite(ts)) continue
|
|
57
59
|
for (const s of sentences(e.text)) {
|
|
@@ -103,13 +105,33 @@ export function interestOf(events = [], now = Date.now(), { minHits = INTEREST_M
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
|
106
|
-
*
|
|
108
|
+
* 把一整句话收成一个能读的短标签(2026-11-05 用户 m02562:原来的整句看不懂)。
|
|
109
|
+
*
|
|
110
|
+
* 四步,每一步都只在做「去掉不是主题的部分」,不改她自己的说法:
|
|
111
|
+
* 1. 去掉开头的旁注(`(她主动说)`这种,是我们加的标注,不是她的话);
|
|
112
|
+
* 2. 内层引号换成双引号,避免 `「「` 嵌套;
|
|
113
|
+
* 3. 断在第一个逗号——后面的多半是她对这句话的解释;
|
|
114
|
+
* 4. 太长再截断。
|
|
107
115
|
*/
|
|
116
|
+
export const TOPIC_LABEL_MAX = 24
|
|
117
|
+
|
|
118
|
+
export function topicLabel(text, max = TOPIC_LABEL_MAX) {
|
|
119
|
+
let s = String(text ?? '').trim()
|
|
120
|
+
s = s.replace(/^([^)]*)\s*/, '')
|
|
121
|
+
s = s.replace(/「/g, '“').replace(/」/g, '”')
|
|
122
|
+
s = s.split(/[,,。;;]/)[0].trim()
|
|
123
|
+
s = s.replace(/^[“”"']+|[“”"']+$/g, '')
|
|
124
|
+
if (!s) return ''
|
|
125
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 摆到桌上的那一行。没有就返回空串(调用方直接不 push)。 */
|
|
108
129
|
export function renderInterest(items = []) {
|
|
109
130
|
const first = Array.isArray(items) ? items[0] : null
|
|
110
131
|
if (!first?.text) return ''
|
|
111
|
-
const
|
|
112
|
-
return
|
|
132
|
+
const label = topicLabel(first.text)
|
|
133
|
+
if (!label) return ''
|
|
134
|
+
return `你最近总说到「${label}」。`
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
/** 把兴趣当劲儿的一笔冲动:有就返回 true。 */
|
package/lib/log.js
CHANGED
|
@@ -218,6 +218,27 @@ export function redactConfig(cfg) {
|
|
|
218
218
|
return clone
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* 事件流尾巴的一行,进导出包之前过一遍。
|
|
223
|
+
*
|
|
224
|
+
* 设置页答应了「不含正文」「没有她说过的话」,导出又是让人转手交给别的 AI 的。
|
|
225
|
+
* 用白名单而不是删 text:refuse.quote、ask.subject 也带原话,以后加的字段也可能带。
|
|
226
|
+
* meta 不走白名单:`watched:` 这类计数和失败记录,正是这段尾巴存在的理由。它和别的行
|
|
227
|
+
* 一样还要过 scrub。
|
|
228
|
+
*/
|
|
229
|
+
function exportEventLine(line) {
|
|
230
|
+
let ev = null
|
|
231
|
+
try {
|
|
232
|
+
ev = JSON.parse(line)
|
|
233
|
+
} catch { /* 下面按坏行算 */ }
|
|
234
|
+
// 解析不了、不是对象(裸字符串之类),或没有 type 这个基本字段:不知道里面是什么,只报长度
|
|
235
|
+
if (!ev || typeof ev !== 'object' || Array.isArray(ev) || typeof ev.type !== 'string') return `(坏行,长 ${line.length} 字符,内容不导出)`
|
|
236
|
+
if (ev.type === 'meta') return line
|
|
237
|
+
const { id, ts, type } = ev
|
|
238
|
+
const flags = Array.isArray(ev.flags) ? ev.flags : []
|
|
239
|
+
return JSON.stringify({ id, ts, type, flags, textLen: typeof ev.text === 'string' ? ev.text.length : 0 })
|
|
240
|
+
}
|
|
241
|
+
|
|
221
242
|
/**
|
|
222
243
|
* 导出包:一次给 AI 看够。
|
|
223
244
|
* 顺序按"排查时最先想看什么"排:环境 → 模型 → 自检 → 工具面 → 事件流 → 日志 → 宿主报错。
|
|
@@ -286,7 +307,7 @@ export function buildBundle(root, {
|
|
|
286
307
|
if (eventLines > 0) {
|
|
287
308
|
const ev = tailEvents(root, { lines: eventLines })
|
|
288
309
|
out.push('', `--- 事件流(最近 ${ev.lines.length} 条,文件共 ${ev.bytes} 字节)---`)
|
|
289
|
-
out.push(ev.exists ? (ev.lines.length ? ev.lines.map((l) => scrub(l)).join('\n') : '(空)') : '(还没有 events.jsonl)')
|
|
310
|
+
out.push(ev.exists ? (ev.lines.length ? ev.lines.map((l) => scrub(exportEventLine(l))).join('\n') : '(空)') : '(还没有 events.jsonl)')
|
|
290
311
|
}
|
|
291
312
|
|
|
292
313
|
const log = tailLog(root, { lines })
|