@ssamurias/dsh-fish 0.9.0 → 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 +35 -4
- package/CHANGELOG.md +46 -1
- package/README.md +11 -6
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE.md +151 -0
- package/lib/ask.js +18 -5
- package/lib/browse.js +1 -0
- package/lib/capture.js +16 -1
- package/lib/client.js +50 -11
- package/lib/comment.js +74 -0
- package/lib/compact.js +45 -1
- package/lib/curiosity.js +6 -2
- package/lib/derive.js +11 -2
- package/lib/desk.js +3 -1
- package/lib/dreamtable.js +13 -2
- package/lib/drive.js +14 -2
- package/lib/freewake.js +4 -1
- package/lib/home.js +87 -22
- package/lib/index.js +1 -1
- package/lib/interest.js +3 -1
- package/lib/look.js +23 -3
- package/lib/observe.js +9 -0
- package/lib/owner.js +5 -2
- package/lib/persona.js +16 -2
- package/lib/proactive.js +67 -12
- package/lib/scheduler.js +99 -27
- package/lib/selfdoc.js +5 -3
- package/lib/selfupdate.js +8 -1
- package/lib/settings.js +5 -0
- package/lib/status.js +31 -3
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +5 -1
- package/lib/upgrade.js +4 -1
- package/package.json +2 -1
- package/scripts/fix-legacy-link.mjs +197 -90
package/lib/scheduler.js
CHANGED
|
@@ -16,7 +16,7 @@ import { join } from 'node:path'
|
|
|
16
16
|
import { deriveSelf } from './derive.js'
|
|
17
17
|
import { validateEvent } from './schema.js'
|
|
18
18
|
import { readEvents, appendEvent, rootDir, ensureHer, paths, readJson } from './store.js'
|
|
19
|
-
import { loadConfig, dateKey, inWindow, pickWakeTime } from './settings.js'
|
|
19
|
+
import { loadConfig, dateKey, inWindow, pickWakeTime, updateConfig } from './settings.js'
|
|
20
20
|
import { plan } from './generator.js'
|
|
21
21
|
import { findTemplate } from './events.js'
|
|
22
22
|
import { offerPrompt, SLEEP_TODAY } from './persona.js'
|
|
@@ -29,17 +29,18 @@ import { seenOn } from './whatseen.js'
|
|
|
29
29
|
import { renderWatchList } from './history.js'
|
|
30
30
|
import { fetchCover, coverLines } from './cover.js'
|
|
31
31
|
import { riskState, browsePlan, noteRisk, isRiskCode } from './risk.js'
|
|
32
|
-
import { scanReplies, renderReplies } from './comment.js'
|
|
32
|
+
import { scanReplies, renderReplies, repliesScanDue } from './comment.js'
|
|
33
33
|
import { flushReport } from './report.js'
|
|
34
|
-
import { talkSessionId } from './home.js'
|
|
34
|
+
import { talkSessionId, homeStatus } from './home.js'
|
|
35
35
|
import { refreshSelfDoc } from './selfdoc.js'
|
|
36
|
-
import { setWakeSection, clearWakeSection, getWakeSection } from './identity.js'
|
|
36
|
+
import { setWakeSection, clearWakeSection, getWakeSection, liveAgentOf } from './identity.js'
|
|
37
|
+
import { queueNote, deliverQueued } from './sessionnote.js'
|
|
37
38
|
import { awayNow, awayLine } from './outing.js'
|
|
38
39
|
import { askInChat, picked } from './askinchat.js'
|
|
39
40
|
import { restedToday, settleStaleAsks } from './ask.js'
|
|
40
|
-
import { proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
|
|
41
|
+
import { proactiveConfig, proactiveState, proactiveSection, recentMaterial, looseEnds, TOOL_ASK_NOTE } from './proactive.js'
|
|
41
42
|
import { deliverWake, ensureSessionLive, WAKE_PING } from './deliver.js'
|
|
42
|
-
import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid } from './compact.js'
|
|
43
|
+
import { followWake, planWakeCompaction, compactWake, looksLikeNothingSaid, looksLikeStreamFailure } from './compact.js'
|
|
43
44
|
import { logLine } from './log.js'
|
|
44
45
|
import { recordGrant } from './grant.js'
|
|
45
46
|
import { readNotes, renderNotes } from './note.js'
|
|
@@ -95,18 +96,27 @@ const DAY_END_CUE = '(今天到这里了。这一刻是我自己的。)'
|
|
|
95
96
|
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
*
|
|
99
|
+
* 等这一轮真说完:认准「我们自己排进去的那一轮」的 turn/end。
|
|
99
100
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* 于是 out/YYYY-MM-DD.md
|
|
101
|
+
* 为什么要有这一步(真机抓到):`sessionController.prompt()` 在把消息**排进队列**时就
|
|
102
|
+
* resolve 了,模型还在后面慢慢想。之前紧接着就 flushReport,报告在她开口前 8 秒就写完,
|
|
103
|
+
* 于是 out/YYYY-MM-DD.md 永远显示「今天她什么都没说」——她其实说了,只是晚了。
|
|
103
104
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
105
|
+
* 坑(issue #36):以前只认会话不认回合——同一会话里任何一个 turn/end 都算数。
|
|
106
|
+
* 手动「叫她一次」时她往往正开在别的一轮里(比如他刚发过话),宿主的 `agent.followup`
|
|
107
|
+
* 会把我们这条排进下一轮,于是**当前那一轮**先 turn/end,处境段(fish:wake)当场被清,
|
|
108
|
+
* 真正醒来那一轮反而什么都没有。
|
|
109
|
+
*
|
|
110
|
+
* 现在的判据:监听挂上之后,必须先见到本条会话的 `turn/start`,之后的 `turn/end`
|
|
111
|
+
* 才算「我们这一轮说完了」;turn/start 之前来的 turn/end 属于上一轮,忽略。
|
|
112
|
+
* 拿不到事件总线(单测/老宿主)时退回原来的即时行为,反正就绪后写一次就够了,
|
|
113
|
+
* 而且 flushReport 是幂等的重写,多写一次不会错。
|
|
107
114
|
*/
|
|
108
115
|
function watchTurnEnd(ctx, sessionId, done) {
|
|
109
116
|
let fired = false
|
|
117
|
+
// 宿主没给事件总线:没有回合可认,退回「等一会儿就动手」的老行为。
|
|
118
|
+
const noBus = typeof ctx.on !== 'function'
|
|
119
|
+
let started = noBus
|
|
110
120
|
const once = () => {
|
|
111
121
|
if (fired) return
|
|
112
122
|
fired = true
|
|
@@ -115,7 +125,8 @@ function watchTurnEnd(ctx, sessionId, done) {
|
|
|
115
125
|
const off = ctx.on?.('session/event', (session, event) => {
|
|
116
126
|
const id = session?.header?.id ?? session?.id
|
|
117
127
|
if (id !== sessionId) return
|
|
118
|
-
if (event?.type === 'turn/
|
|
128
|
+
if (event?.type === 'turn/start') { started = true; return }
|
|
129
|
+
if (event?.type === 'turn/end' && started) once()
|
|
119
130
|
})
|
|
120
131
|
return { off, once, get fired() { return fired } }
|
|
121
132
|
}
|
|
@@ -161,6 +172,17 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
161
172
|
const t = busy.get(id)
|
|
162
173
|
return t !== undefined && Date.now() - t < BUSY_STALE_MS
|
|
163
174
|
}
|
|
175
|
+
// 别的插件知道她在忙(比如在打一局游戏),这个调度器不知道。它们可以
|
|
176
|
+
// `ctx.on('dsh-fish/busy', (sessionId) => true)`,不用知道这个包叫什么、装在哪。
|
|
177
|
+
// 监听要同步返回,只有 true 算忙。抛错、返回 Promise 都按不忙算;Promise 的拒绝要接住,
|
|
178
|
+
// 宿主把未处理的拒绝当致命错误,别人的一个监听不该把整个进程带走。
|
|
179
|
+
const busyElsewhere = (id) => {
|
|
180
|
+
try {
|
|
181
|
+
const r = ctx.bail?.('dsh-fish/busy', id)
|
|
182
|
+
if (typeof r?.then === 'function') r.then(undefined, (e) => log('dsh-fish/busy 监听拒绝', e?.message ?? e))
|
|
183
|
+
return r === true
|
|
184
|
+
} catch { return false }
|
|
185
|
+
}
|
|
164
186
|
const waitIdle = async (id) => {
|
|
165
187
|
const until = Date.now() + IDLE_WAIT_MS
|
|
166
188
|
while (!disposed && isBusy(id) && Date.now() < until) await new Promise((r) => setTimeout(r, 2000))
|
|
@@ -187,8 +209,9 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
187
209
|
// 2026-11-04(用户 m01466):把她的成长档案交给判定函数。
|
|
188
210
|
// 之前这里是 `proactiveState({ config, events, now })` —— 它收不到 model,
|
|
189
211
|
// 于是第 3 天和第 27 天算出来的概率一模一样(「自己开口的出口不该是恒温的」)。
|
|
190
|
-
// deriveSelf
|
|
191
|
-
|
|
212
|
+
// 关着的时候判定用不上 model,不算:tick 每分钟一次,deriveSelf 是同步的,
|
|
213
|
+
// 事件流长了会卡住主线程,而这个开关默认是关的。开着时和原来一样,先算再判定。
|
|
214
|
+
const model = proactiveConfig(config).enabled ? deriveSelf(events, { growthDays: config.growthDays }) : null
|
|
192
215
|
const st = proactiveState({ config, events, now, model })
|
|
193
216
|
if (!st.ok) {
|
|
194
217
|
if (st.why && st.why !== proactiveSkip) {
|
|
@@ -215,7 +238,8 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
215
238
|
const seen = lastSeenAt(events)
|
|
216
239
|
const echoBack = !!(leave && seen && seen >= leave && now - leave <= 72 * 3600_000)
|
|
217
240
|
const driveDesk = { gaps: gaps.length, stale: 0, echo: echoBack, favorite: interest.length > 0 }
|
|
218
|
-
const
|
|
241
|
+
const pc = proactiveConfig(config)
|
|
242
|
+
const drv = driveOf({ events, now, stage: model.stage, desk: driveDesk, seenCountsAsHeard: pc.idleCountsSeen })
|
|
219
243
|
// 空桌不醒(2026-11-05,用户 m02937):桌上什么都没有时,劲儿再满也不起——
|
|
220
244
|
// 否则她只能回一句「没什么要说的」,既占额度又在对话里留一条噪音。
|
|
221
245
|
if (!deskHasMaterial(driveDesk)) {
|
|
@@ -237,10 +261,19 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
237
261
|
if (!sc) return false
|
|
238
262
|
const sessionId = await talkSessionId(ctx, { root, log })
|
|
239
263
|
if (!sessionId) return false
|
|
264
|
+
// 她正忙时不投:处境段会挂在正在跑的那一轮上,那一轮结束又把它清掉,
|
|
265
|
+
// 真正的这一轮就只收到一个空格。过 5 分钟再看(定时叫醒那条路一直有这个判断)。
|
|
266
|
+
const elsewhere = busyElsewhere(sessionId)
|
|
267
|
+
if (elsewhere || isBusy(sessionId)) {
|
|
268
|
+
lastProbe = now - 25 * 60_000
|
|
269
|
+
const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy)' : '她正忙'
|
|
270
|
+
if (proactiveSkip !== why) { proactiveSkip = why; log('proactive skip', why) }
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
240
273
|
|
|
241
274
|
const material = recentMaterial(events, { now })
|
|
242
275
|
const loose = looseEnds(events, { now })
|
|
243
|
-
const text = proactiveSection({ now, state: st, material, loose })
|
|
276
|
+
const text = proactiveSection({ now, state: st, material, loose, sendTools: pc.sendTools, countSeen: pc.idleCountsSeen })
|
|
244
277
|
|
|
245
278
|
const requestId = `proactive-${now}`
|
|
246
279
|
// 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end(投递只等入队,见 wakewin.js),
|
|
@@ -269,7 +302,7 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
269
302
|
|
|
270
303
|
// 投递之前先把这一轮盯起来(2026-11-05,用户 m00803)。认领信号是 rpcId:
|
|
271
304
|
// 每次投递一个唯一值,命中之前一律当「还没开始」,所以早挂不会认错轮。
|
|
272
|
-
const follow = followWake(ctx, { rpcId: requestId, log })
|
|
305
|
+
const follow = followWake(ctx, { rpcId: requestId, log, sendTools: pc.sendTools })
|
|
273
306
|
|
|
274
307
|
// 2026-11-04(用户 m00428):投递换成正解 —— 自建 `source.kind='fish-wake'`
|
|
275
308
|
// 的消息交给 `agent.followup()`。宿主把它画成一张默认收起的折叠条,
|
|
@@ -314,7 +347,12 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
314
347
|
const meta = { day: model.day, stage: model.stage, source: 'timer' }
|
|
315
348
|
// 「没什么要说的」不是开口(2026-11-05,用户 m02937):她写一句元话语
|
|
316
349
|
// 交代「这轮没话说」,在文本界面上几乎不可避免;按静默记、折掉,别当一次开口。
|
|
317
|
-
|
|
350
|
+
// 配了发送工具时正文到不了人,只认工具;没配时还是看正文
|
|
351
|
+
// 上游断流写成的那行红字也不是她开口(2026-11-06 真机看到的 `❌ [ACP] stream error`)
|
|
352
|
+
const broken = out.spoke === true && looksLikeStreamFailure(out.said)
|
|
353
|
+
const quiet = pc.sendTools.length > 0
|
|
354
|
+
? out.toolSpoke !== true
|
|
355
|
+
: (out.spoke !== true || broken || looksLikeNothingSaid(out.said))
|
|
318
356
|
if (!quiet) {
|
|
319
357
|
await appendEvent({ type: 'meta', text: `proactive-turn:${dk}:${now}` }, meta, root).catch(() => {})
|
|
320
358
|
log('proactive', `开了一次空手回合,她开口了(劲儿 ${drv.value.toFixed(2)})`)
|
|
@@ -322,13 +360,14 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
322
360
|
}
|
|
323
361
|
await appendEvent({ type: 'meta', text: `proactive-silent:${dk}:${now}` }, meta, root).catch(() => {})
|
|
324
362
|
// 折叠只在拿到闭合区间时做:超时、或没认出那条 ping,out.endSeq 都是 -1。
|
|
325
|
-
const plan = out.endSeq >= 0
|
|
363
|
+
const plan = out.endSeq >= 0 && pc.foldSilent
|
|
326
364
|
? planWakeCompaction(out.events, { startSeq: out.startSeq, endSeq: out.endSeq, rpcId: requestId })
|
|
327
365
|
: null
|
|
328
366
|
// 一轮可能被宿主的投影切成几段:各段单独换,边界节点原样留着。
|
|
329
367
|
let folded = 0
|
|
330
368
|
if (plan) for (const run of plan.runs) if (await compactWake(out.session, run, { log })) folded += 1
|
|
331
|
-
const tail =
|
|
369
|
+
const tail = broken ? ',这一轮上游断流了(那行报错不算她开口)'
|
|
370
|
+
: out.spoke === true ? ',只说了句没什么要说的' : ''
|
|
332
371
|
log('proactive', folded > 0
|
|
333
372
|
? `醒了一次,没有开口${tail}(这一轮折成 ${folded} 行,盖住 ${plan.shadowed} 个节点)`
|
|
334
373
|
: `醒了一次,没有开口${tail}(${out.ok ? '这轮没什么可折' : out.why})`)
|
|
@@ -405,6 +444,31 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
405
444
|
return
|
|
406
445
|
}
|
|
407
446
|
|
|
447
|
+
// ---- 有人回她了吗(2026-11-06,用户 m01641)----
|
|
448
|
+
// 原来这里只有 wake() 拼正文时顺带扫一次:没被叫醒的那天就完全看不到回复,
|
|
449
|
+
// 而设置页那个「多久看一次有没有人回复」一直是个空旋钮(scanEveryMin
|
|
450
|
+
// 全仓没有消费者)。现在按那个间隔真去问一次,问到了就把那几句排队投给她。
|
|
451
|
+
// 投递用 queueNote:她醒着就立刻补进当前这条会话,没开着就等她下次打开。
|
|
452
|
+
try {
|
|
453
|
+
if (repliesScanDue(config?.comment, now)) {
|
|
454
|
+
const waiting = await scanReplies({ root, events, dateKey: dk }).catch(() => [])
|
|
455
|
+
// 无论有没有人回都记一次:不记的话每 30 秒就会重新问一遍 B 站(-412 就是这么撞出来的)。
|
|
456
|
+
await updateConfig({ comment: { lastScanAt: new Date(now).toISOString() } }, root).catch(() => {})
|
|
457
|
+
if (waiting.length) {
|
|
458
|
+
const txt = renderReplies(waiting)
|
|
459
|
+
if (txt) {
|
|
460
|
+
await queueNote(root, txt).catch(() => null)
|
|
461
|
+
const h = await homeStatus(root).catch(() => null)
|
|
462
|
+
const agent = h?.sessionId ? liveAgentOf(ctx, h.sessionId) : null
|
|
463
|
+
if (agent) await deliverQueued(agent, root, { home: true }).catch(() => null)
|
|
464
|
+
log('comment', `有人回她了(${waiting.length} 条),已经递过去。`)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
log('comment', `扫回复失败:${err?.message ?? err}`)
|
|
470
|
+
}
|
|
471
|
+
|
|
408
472
|
// ---- 主动说话(0.8.1 本机实验,2026-10-30 用户 m04610)----
|
|
409
473
|
// 它不是唤醒:不占 wakesPerDay,也不推成长事件。只在 .dev 下生效。
|
|
410
474
|
// 开了就 return——这一趟由她自己决定说还是不说,不再叠一次醒一醒。
|
|
@@ -470,8 +534,9 @@ export function startScheduler(ctx, deps = {}) {
|
|
|
470
534
|
// 轮换之后这里查的是旧会话忙不忙,新会话正说着话也照样插进去。
|
|
471
535
|
const sid = (await readJson(join(paths(root).root, 'home.json'), null).catch(() => null))?.sessionId
|
|
472
536
|
?? deps.sessionId
|
|
473
|
-
|
|
474
|
-
|
|
537
|
+
const elsewhere = sid ? busyElsewhere(sid) : false
|
|
538
|
+
if (sid && (elsewhere || isBusy(sid))) {
|
|
539
|
+
const why = elsewhere ? '别的插件说她在忙(dsh-fish/busy),等一等再叫' : '她正在说话,等这一轮结束再叫'
|
|
475
540
|
const next = shouldLogSkip(skipLogged, dk, now, why)
|
|
476
541
|
if (next) { skipLogged = next; log('skip', why) }
|
|
477
542
|
return
|
|
@@ -675,7 +740,13 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
675
740
|
{
|
|
676
741
|
try {
|
|
677
742
|
const { events: evs } = await readEvents(root)
|
|
678
|
-
|
|
743
|
+
// 2026-11-06(issue #42):「兴趣」关掉时不再摆旧句子。
|
|
744
|
+
// 原来 freeWakeFor 只要 tableText 非空就判成 table,而 tableText 根本不看 interest 开关——
|
|
745
|
+
// 于是「关掉摆东西」之后,每 6 小时还是会因为旧句子空醒一次,而且那一轮既不想一想、
|
|
746
|
+
// 也不给规则申请、也不给「今天到这里」:14 天里 14 次全是旧句子。
|
|
747
|
+
// 旧句子属于「桌上那几样东西」里的一样,跟着兴趣开关走才自洽。
|
|
748
|
+
const staleOn = config?.interest?.enabled !== false
|
|
749
|
+
if (staleOn && !dreamOn(evs, model.day).length) {
|
|
679
750
|
const shownToday = evs.some((e) => e?.type === 'meta' && String(e.text ?? '').startsWith(`table:${dk}`))
|
|
680
751
|
const [{ outings }, { possessions }] = await Promise.all([readOutings(root), readPossessions(root)])
|
|
681
752
|
const tripSessions = new Set(
|
|
@@ -971,13 +1042,14 @@ async function wake(ctx, root, model, config, decision, dk, log, deps = {}) {
|
|
|
971
1042
|
}
|
|
972
1043
|
|
|
973
1044
|
// 刷站那几十秒里她可能刚好开始了别的一轮(有人找她)。等那一轮说完再挂处境、再叫:
|
|
974
|
-
//
|
|
1045
|
+
// 挂早了会被那一轮的 turn/end 收走(issue #36 之前 watcher 只认会话不认回合,
|
|
1046
|
+
// 现在它至少要见到我们自己这一轮的 turn/start 才算数,双保险)。
|
|
975
1047
|
if (typeof deps.waitIdle === 'function') await deps.waitIdle(sessionId)
|
|
976
1048
|
|
|
977
1049
|
// 这一轮她写的话不算他来过:唤醒时间窗开到 turn/end,不是开到入队(见 wakewin.js)。
|
|
978
1050
|
// turn/end 不来时跟处境一起由下面的兜底关;没送进去就在 catch 里关。
|
|
979
1051
|
// 处境代次(2026-11-04,用户 m01466):见 maybeProactive 里的同一处注释。
|
|
980
|
-
//
|
|
1052
|
+
// 清的时候要带着自己挂上去时拿到的号,别把别人后来挂的处境一起抹了。
|
|
981
1053
|
let gen = 0
|
|
982
1054
|
const releaseWake = holdWake()
|
|
983
1055
|
const watcher = watchTurnEnd(ctx, sessionId, () => { releaseWake(); void writeReport(); clearWakeSection(gen) })
|
package/lib/selfdoc.js
CHANGED
|
@@ -27,7 +27,8 @@ import { join } from 'node:path'
|
|
|
27
27
|
import { rootDir, paths, readEvents, readJson, writeAtomic, listDiary } from './store.js'
|
|
28
28
|
import { loadConfig } from './settings.js'
|
|
29
29
|
import { deriveSelf } from './derive.js'
|
|
30
|
-
import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF,
|
|
30
|
+
import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF, proactiveBrief, DREAM_BRIEF, SCHEDULE_BRIEF, DIARY_BRIEF, SLEEP_BRIEF } from './persona.js'
|
|
31
|
+
import { proactiveConfig } from './proactive.js'
|
|
31
32
|
import { LOOK_BRIEF, WATCH_RULE } from './browse.js'
|
|
32
33
|
import { activeCarry } from './home.js'
|
|
33
34
|
import { activeGrants } from './unlock.js'
|
|
@@ -120,7 +121,7 @@ export async function continuityOf(root = rootDir(), now = Date.now()) {
|
|
|
120
121
|
* @param {object} model deriveSelf 的结果
|
|
121
122
|
* @returns {string}
|
|
122
123
|
*/
|
|
123
|
-
export function renderSelfDoc(model, { budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false } = {}) {
|
|
124
|
+
export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false } = {}) {
|
|
124
125
|
const head = [
|
|
125
126
|
// 这里**故意不写**「由 dsh-fish 自动生成」这类话。
|
|
126
127
|
// 这个文件会被宿主原样渲染进她的会话——她能看见每一行。
|
|
@@ -166,7 +167,7 @@ export function renderSelfDoc(model, { budget, carry, continuity, browse = true,
|
|
|
166
167
|
...(dream ? [DREAM_BRIEF, ''] : []),
|
|
167
168
|
// 主动说话(2026-10-30,用户 m04610):her_say 只在 .dev 下注册,
|
|
168
169
|
// 说明书也只在同一个判据下挂——公开版不能让「她」以为有这个工具。
|
|
169
|
-
...(proactive ? [
|
|
170
|
+
...(proactive ? [proactiveBrief(sendTools), ''] : []),
|
|
170
171
|
// 定时提醒(2026-10-30,用户 m04335 / m04504):默认不放,只有用户把
|
|
171
172
|
// schedule_create 加进白名单才挂——不然就是告诉她「你有这个工具」,伸手却没有。
|
|
172
173
|
...(schedule ? [SCHEDULE_BRIEF, ''] : []),
|
|
@@ -265,6 +266,7 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
|
|
|
265
266
|
// 梦(2026-10-30 m02837)与主动说话(m04610):0.9.0 起公开,两处都恒为真。
|
|
266
267
|
dream: true,
|
|
267
268
|
proactive: true,
|
|
269
|
+
sendTools: proactiveConfig(config).sendTools,
|
|
268
270
|
// 定时工具默认不放;用户在白名单里加了 schedule_create 才告诉他这条路通。
|
|
269
271
|
schedule: (config?.tools?.allow ?? []).some((n) => String(n).trim() === 'schedule_create'),
|
|
270
272
|
})
|
package/lib/selfupdate.js
CHANGED
|
@@ -137,7 +137,14 @@ export function profileDirOf(moduleUrl) {
|
|
|
137
137
|
// 两种都要认得,否则从 0.8.9 之前装上来的用户会被当成"没有 profile → 开发版"而永远升不了级。
|
|
138
138
|
for (const marker of [`/node_modules/${PKG}/`, `/node_modules/${LEGACY_PKG}/`]) {
|
|
139
139
|
const i = file.lastIndexOf(marker)
|
|
140
|
-
if (i
|
|
140
|
+
if (i < 0) continue
|
|
141
|
+
const base = file.slice(0, i)
|
|
142
|
+
// pnpm 的 isolated 布局(issue #43):真身常在
|
|
143
|
+
// `<profile>/node_modules/.pnpm/@ssamurias+dsh-fish@0.9.1/node_modules/@ssamurias/dsh-fish/lib/...`,
|
|
144
|
+
// 直接截到 marker 会得到 `.pnpm/.../node_modules`,那里没有 package.json,
|
|
145
|
+
// 于是 kind 判成 missing、managed 变 false,下载按钮永远不出现。往上退到 profile 根。
|
|
146
|
+
const pnpm = base.indexOf('/node_modules/.pnpm/')
|
|
147
|
+
return pnpm >= 0 ? base.slice(0, pnpm) : base
|
|
141
148
|
}
|
|
142
149
|
return ''
|
|
143
150
|
}
|
package/lib/settings.js
CHANGED
|
@@ -70,6 +70,11 @@ export const DEFAULTS = {
|
|
|
70
70
|
gainPerHour: 0.05, // 超过 minIdleHours 之后,每多一小时加多少
|
|
71
71
|
maxChance: 0.6, // 概率上限
|
|
72
72
|
cooldownHours: 4, // 两次之间至少隔多久
|
|
73
|
+
// 下面三项给「对话要靠工具才发得出去」的部署(比如宿主里另有插件把多个聊天接进这条会话,
|
|
74
|
+
// 再给她一个发送工具)。默认值和原来的行为一样。
|
|
75
|
+
sendTools: [], // 这些工具调成功才算她开了口;空 = 只认正文
|
|
76
|
+
foldSilent: true, // 没开口的一轮折成一行;一条会话用很久、上下文很大时可以关
|
|
77
|
+
idleCountsSeen: false, // 「他来过」(seen:)也算打断安静;她的对话不进事件流时要开
|
|
73
78
|
},
|
|
74
79
|
|
|
75
80
|
// 她"不想看"的东西(可追加,用户说以后还会加)
|
package/lib/status.js
CHANGED
|
@@ -44,7 +44,7 @@ function dayMarker(text, prefix, dk) {
|
|
|
44
44
|
* @param {{events:Array, config:object, now?:Date}} input
|
|
45
45
|
* @returns {{phase:string, label:string, detail:string, since:string, minutesAgo:number|null,
|
|
46
46
|
* plannedWakeAt:string|null, nextWakeAt:string|null, awake:boolean,
|
|
47
|
-
* today:{wakes:number,idleWakes:number,browsed:number,said:number,comments:number,dairies:number},
|
|
47
|
+
* today:{wakes:number,idleWakes:number,browsed:number,said:number,chat:number,comments:number,dairies:number},
|
|
48
48
|
* timeline:Array<{at:string,kind:string,text:string,idle?:boolean}>}}
|
|
49
49
|
*/
|
|
50
50
|
export function deriveStatus({ events = [], config = {}, now = new Date() } = {}) {
|
|
@@ -73,7 +73,10 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
73
73
|
|
|
74
74
|
// ---- 把今天切成"醒来块":每个 wake: 是起点,动作挂到它下面 ----
|
|
75
75
|
const blocks = []
|
|
76
|
-
|
|
76
|
+
// chat(issue #32):对话里她说的那些行。它们**不是**她在这一轮里主动做的事——
|
|
77
|
+
// 一次叫醒正好撞上他连说几十句,时间线 40 条会被这些行占满,`idleWakes` 也会被
|
|
78
|
+
// 一句「嗯」清零。所以单列一个计数,不进 said、不算 done、时间线上折叠成一行。
|
|
79
|
+
const counts = { wakes: 0, idleWakes: 0, browsed: 0, said: 0, chat: 0, comments: 0, dairies: 0 }
|
|
77
80
|
let cur = null
|
|
78
81
|
const orphan = { at: today[0]?.t ?? nowMs, actions: [], wake: null }
|
|
79
82
|
|
|
@@ -128,6 +131,25 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
128
131
|
}
|
|
129
132
|
// 她的发言
|
|
130
133
|
if (e?.type === 'utterance' || e?.type === 'narration' || e?.type === 'revise') {
|
|
134
|
+
// issue #32:`flags: ['uncommitted']` 是 turns.js 在她**答他的话**时打的标
|
|
135
|
+
// (settle 写进来的对话行,不是她自己认下的判断)。这类行以前混在 said 里:
|
|
136
|
+
// 一次 wake 加 45 句聊天,时间线 40 条全是「说了:…」,`idleWakes` 也从 1 变 0。
|
|
137
|
+
// 现在单列成 chat,只在时间线上折成一行「聊了 N 句」。
|
|
138
|
+
const isChat = Array.isArray(e?.flags) && e.flags.includes('uncommitted')
|
|
139
|
+
if (isChat) {
|
|
140
|
+
const last = cur ?? orphan
|
|
141
|
+
const prev = last.actions[last.actions.length - 1]
|
|
142
|
+
// 连续多句合进同一条,别让一次长对话把时间线刷成弹幕。
|
|
143
|
+
if (prev && prev.kind === 'chat') {
|
|
144
|
+
prev.n += 1
|
|
145
|
+
prev.at = t
|
|
146
|
+
prev.text = `聊了 ${prev.n} 句`
|
|
147
|
+
} else {
|
|
148
|
+
push(t, 'chat', '聊了 1 句', { n: 1 })
|
|
149
|
+
}
|
|
150
|
+
counts.chat += 1
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
131
153
|
counts.said += 1
|
|
132
154
|
const one = text.replace(/\s+/g, ' ').slice(0, 60)
|
|
133
155
|
push(t, 'say', one ? `说了:「${one}${text.length > 60 ? '…' : ''}」` : '说了点什么')
|
|
@@ -143,7 +165,9 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
143
165
|
|
|
144
166
|
// ---- 醒着但什么也没做的块,记成 idle ----
|
|
145
167
|
for (const b of blocks) {
|
|
146
|
-
|
|
168
|
+
// issue #32:chat 跟 quiet/fail 一样不算「做了什么」——聊天是她答他的话,
|
|
169
|
+
// 不是这一轮她自己起的动作。否则他在她醒着时随便接一句,这次 wake 就不算发呆了。
|
|
170
|
+
const done = b.actions.filter((a) => a.kind !== 'quiet' && a.kind !== 'fail' && a.kind !== 'chat')
|
|
147
171
|
b.idle = done.length === 0
|
|
148
172
|
}
|
|
149
173
|
counts.idleWakes = blocks.filter((b) => b.idle).length
|
|
@@ -170,6 +194,10 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
170
194
|
} else if (recent && lastKind === 'say') {
|
|
171
195
|
phase = 'talking'; label = '在想怎么回你'
|
|
172
196
|
detail = activeBlock.actions[activeBlock.actions.length - 1].text
|
|
197
|
+
} else if (recent && lastKind === 'chat') {
|
|
198
|
+
// 刚跟他聊过几句:还是「在说话」,只是这些行不算她主动做的事(issue #32)。
|
|
199
|
+
phase = 'talking'; label = '在想怎么回你'
|
|
200
|
+
detail = ''
|
|
173
201
|
} else if (recent && lastKind === 'diary') {
|
|
174
202
|
phase = 'writing'; label = '在写日记'
|
|
175
203
|
detail = ''
|
package/lib/store.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* API 全部 async:原子写用「写 tmp → rename」,避免断电留半行。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { randomBytes } from 'node:crypto'
|
|
17
18
|
import { promises as fs } from 'node:fs'
|
|
18
19
|
import { dirname, join } from 'node:path'
|
|
19
20
|
import { validateEvent, EVENT_SCHEMA_VERSION } from './schema.js'
|
|
@@ -89,7 +90,8 @@ const RENAME_RETRY = new Set(['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY'])
|
|
|
89
90
|
|
|
90
91
|
export async function writeAtomic(file, text, mode) {
|
|
91
92
|
await ensureDir(dirname(file))
|
|
92
|
-
|
|
93
|
+
// 随机后缀:同一进程同一毫秒的两次写不能共用一个临时文件,否则内容会交错,写出坏 JSON
|
|
94
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
|
|
93
95
|
await fs.writeFile(tmp, text, 'utf8')
|
|
94
96
|
if (mode) await fs.chmod(tmp, mode).catch(() => {})
|
|
95
97
|
for (let i = 0; i < RENAME_WAIT.length; i++) {
|
|
@@ -107,9 +109,19 @@ export async function writeAtomic(file, text, mode) {
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// issue #38:手改过的 JSON 常带 UTF-8 BOM(PowerShell 5.1 的
|
|
113
|
+
// `Set-Content -Encoding UTF8`、记事本「带 BOM 的 UTF-8」都会写)。
|
|
114
|
+
// `JSON.parse` 见到 BOM 直接抛 SyntaxError,于是**内容完好的文件**被下面的
|
|
115
|
+
// 坏文件分支改名成 `.corrupt.<时间>`,config.json 退回全默认、owner.json 变 null、
|
|
116
|
+
// home.json 直接新建一条会话——用户以为自己的改动丢了。
|
|
117
|
+
// 只剥开头这一个字符;不动其余内容,也不改行号之外的任何字节。
|
|
118
|
+
function stripBom(text) {
|
|
119
|
+
return typeof text === 'string' && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
export async function readJson(file, fallback = null) {
|
|
111
123
|
try {
|
|
112
|
-
return JSON.parse(await fs.readFile(file, 'utf8'))
|
|
124
|
+
return JSON.parse(stripBom(await fs.readFile(file, 'utf8')))
|
|
113
125
|
} catch (err) {
|
|
114
126
|
if (err.code === 'ENOENT') return fallback
|
|
115
127
|
// 坏文件不静默吞:留证据,返回 fallback 让上层能用
|
package/lib/tenant.js
CHANGED
|
@@ -62,6 +62,18 @@ export async function withFileLock(file, fn, { ttl = LOCK_TTL_MS, timeout = 8_00
|
|
|
62
62
|
await fh.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }))
|
|
63
63
|
break
|
|
64
64
|
} catch (err) {
|
|
65
|
+
// issue #37:Windows 上文件正在被删除时,`open(file,'wx')` 返回 EPERM 而不是
|
|
66
|
+
// EEXIST。以前这会直接抛出去,ensureTalkSession 接住就返回「建会话的锁没拿到」,
|
|
67
|
+
// 这一次换会话就此失败;`test/tenant.test.mjs` 那条偶发红也是同一个原因。
|
|
68
|
+
//
|
|
69
|
+
// 注意**不能并进下面的 EEXIST 分支**:那里 stat 失败就判 stale 然后 continue,
|
|
70
|
+
// 不看 timeout,真的是权限错误时会死循环。EPERM 走独立分支、受 timeout 约束。
|
|
71
|
+
// 只认 win32:POSIX 上 EPERM 通常是真没权限,重试没有意义,照样抛。
|
|
72
|
+
if (err?.code === 'EPERM' && process.platform === 'win32') {
|
|
73
|
+
if (Date.now() - started > timeout) throw err
|
|
74
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
65
77
|
if (err?.code !== 'EEXIST') throw err
|
|
66
78
|
// 锁文件的 mtime 太老 = 上一任进程死在临界区里了(没来得及 unlink),直接接管。
|
|
67
79
|
let stale = false
|
package/lib/turns.js
CHANGED
|
@@ -58,8 +58,12 @@ import { stripToolMarkup, hasToolMarkup } from './markup.js'
|
|
|
58
58
|
/**
|
|
59
59
|
* 我们自己 prompt 进去的消息,rpcId 会带这些前缀。
|
|
60
60
|
* 只是"多一道保险":真正的判据是 clientTimeZone(见 classifyMessage)。
|
|
61
|
+
*
|
|
62
|
+
* `carry` 是换会话前请她留话那条(home.js 的 `carry-ask-`)。少了它,那条
|
|
63
|
+
* 消息会掉进 `kind === 'user'` 且没有 clientTimeZone 的 `unknown` 分支:不进
|
|
64
|
+
* 轮数(行为正确),但每换一次会话就往日志里多一行「认不出来源」(2026-11-06)。
|
|
61
65
|
*/
|
|
62
|
-
const PLUGIN_RPC = /^(wake|say|boot|report|letter|sleep|nudge|probe|turn|sys)[-_]/i
|
|
66
|
+
const PLUGIN_RPC = /^(wake|say|boot|report|letter|sleep|nudge|probe|turn|sys|carry)[-_]/i
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
69
|
* 宿主/我们这边自己拼的、**确定不是人打的** source.kind。
|
package/lib/upgrade.js
CHANGED
|
@@ -64,7 +64,10 @@ export function compareVersion(a, b) {
|
|
|
64
64
|
|
|
65
65
|
async function readJson(file) {
|
|
66
66
|
try {
|
|
67
|
-
|
|
67
|
+
// issue #38:手改过的 package.json / 配置文件可能带 UTF-8 BOM。
|
|
68
|
+
// 这里以前直接 parse,BOM 会让内容完好的文件读成 null(升级自检于是当作没装)。
|
|
69
|
+
const text = await readFile(file, 'utf8')
|
|
70
|
+
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text)
|
|
68
71
|
} catch {
|
|
69
72
|
return null
|
|
70
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssamurias/dsh-fish",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "养一条会自己长大的鱼:没有人格设置,通过事件流长大。她可以自己刷 B 站、会写日记给你看,四个阶段完成后才会开始有立场。状态库在 ~/.dsh-fish,不能删除记录。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"AGENTS.md",
|
|
20
20
|
"CHANGELOG.md",
|
|
21
21
|
"RELEASE-NOTES-*.md",
|
|
22
|
+
"RELEASE.md",
|
|
22
23
|
"scripts"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|