@ssamurias/dsh-fish 0.9.0 → 0.9.2
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 +60 -4
- package/CHANGELOG.md +85 -1
- package/README.md +13 -6
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE.md +152 -0
- package/lib/absence.js +41 -27
- package/lib/ask.js +91 -30
- package/lib/askinchat.js +83 -1
- package/lib/browse.js +1 -0
- package/lib/capture.js +35 -2
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +116 -19
- package/lib/comment.js +111 -2
- package/lib/compact.js +45 -1
- package/lib/curiosity.js +6 -2
- package/lib/deliver.js +46 -0
- package/lib/derive.js +25 -13
- package/lib/desk.js +3 -1
- package/lib/dreamtable.js +13 -2
- package/lib/drive.js +14 -2
- package/lib/events.js +25 -1
- package/lib/freewake.js +4 -1
- package/lib/generator.js +2 -1
- package/lib/grant.js +18 -2
- package/lib/home.js +142 -29
- package/lib/identity.js +111 -14
- package/lib/index.js +50 -10
- package/lib/interest.js +3 -1
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/look.js +23 -3
- package/lib/note.js +255 -20
- package/lib/observe.js +14 -4
- package/lib/owner.js +5 -2
- package/lib/persona.js +26 -9
- package/lib/proactive.js +67 -12
- package/lib/say.js +73 -13
- package/lib/scheduler.js +186 -56
- package/lib/schema.js +18 -0
- package/lib/selfdoc.js +5 -3
- package/lib/selfupdate.js +8 -1
- package/lib/settings.js +5 -0
- package/lib/status.js +56 -6
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +47 -2
- package/lib/upgrade.js +4 -1
- package/package.json +2 -1
- package/scripts/fix-legacy-link.mjs +197 -90
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() } = {}) {
|
|
@@ -71,9 +71,17 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
71
71
|
if (Number.isFinite(at)) plannedWakeAt = at
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// 2026-11-06(0.9.1 反馈):`quiet:` 是「落点到了,但没有可出的事件」,
|
|
75
|
+
// 不是「今天不会醒了」——quiet 之后冷却到期还会真醒(11-05 就是这样)。
|
|
76
|
+
// 所以它能解释「为什么没醒」,但不能用「今天」这种封死的说法。
|
|
77
|
+
const quietToday = today.some(({ e }) => e?.type === 'meta' && String(textOf(e) ?? '').startsWith(`quiet:${dk}`))
|
|
78
|
+
|
|
74
79
|
// ---- 把今天切成"醒来块":每个 wake: 是起点,动作挂到它下面 ----
|
|
75
80
|
const blocks = []
|
|
76
|
-
|
|
81
|
+
// chat(issue #32):对话里她说的那些行。它们**不是**她在这一轮里主动做的事——
|
|
82
|
+
// 一次叫醒正好撞上他连说几十句,时间线 40 条会被这些行占满,`idleWakes` 也会被
|
|
83
|
+
// 一句「嗯」清零。所以单列一个计数,不进 said、不算 done、时间线上折叠成一行。
|
|
84
|
+
const counts = { wakes: 0, idleWakes: 0, browsed: 0, said: 0, chat: 0, comments: 0, dairies: 0 }
|
|
77
85
|
let cur = null
|
|
78
86
|
const orphan = { at: today[0]?.t ?? nowMs, actions: [], wake: null }
|
|
79
87
|
|
|
@@ -120,7 +128,10 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
120
128
|
continue
|
|
121
129
|
}
|
|
122
130
|
if (dayMarker(text, 'quiet', dk) !== null) {
|
|
123
|
-
|
|
131
|
+
// 没有 wake: 就没有「醒」这回事。旧文案「醒了,但今天没有该问的事」
|
|
132
|
+
// 会在时间线上凭空多出一次醒来,还和状态卡「还没醒」互相打脸。
|
|
133
|
+
// 「暂时」也不用「今天」:quiet 不封当天,冷却到期后她照样会醒。
|
|
134
|
+
push(t, 'quiet', '到点了,暂时没有可出的事件')
|
|
124
135
|
continue
|
|
125
136
|
}
|
|
126
137
|
if (dayMarker(text, 'unrecorded', dk) !== null) continue
|
|
@@ -128,6 +139,25 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
128
139
|
}
|
|
129
140
|
// 她的发言
|
|
130
141
|
if (e?.type === 'utterance' || e?.type === 'narration' || e?.type === 'revise') {
|
|
142
|
+
// issue #32:`flags: ['uncommitted']` 是 turns.js 在她**答他的话**时打的标
|
|
143
|
+
// (settle 写进来的对话行,不是她自己认下的判断)。这类行以前混在 said 里:
|
|
144
|
+
// 一次 wake 加 45 句聊天,时间线 40 条全是「说了:…」,`idleWakes` 也从 1 变 0。
|
|
145
|
+
// 现在单列成 chat,只在时间线上折成一行「聊了 N 句」。
|
|
146
|
+
const isChat = Array.isArray(e?.flags) && e.flags.includes('uncommitted')
|
|
147
|
+
if (isChat) {
|
|
148
|
+
const last = cur ?? orphan
|
|
149
|
+
const prev = last.actions[last.actions.length - 1]
|
|
150
|
+
// 连续多句合进同一条,别让一次长对话把时间线刷成弹幕。
|
|
151
|
+
if (prev && prev.kind === 'chat') {
|
|
152
|
+
prev.n += 1
|
|
153
|
+
prev.at = t
|
|
154
|
+
prev.text = `聊了 ${prev.n} 句`
|
|
155
|
+
} else {
|
|
156
|
+
push(t, 'chat', '聊了 1 句', { n: 1 })
|
|
157
|
+
}
|
|
158
|
+
counts.chat += 1
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
131
161
|
counts.said += 1
|
|
132
162
|
const one = text.replace(/\s+/g, ' ').slice(0, 60)
|
|
133
163
|
push(t, 'say', one ? `说了:「${one}${text.length > 60 ? '…' : ''}」` : '说了点什么')
|
|
@@ -143,10 +173,14 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
143
173
|
|
|
144
174
|
// ---- 醒着但什么也没做的块,记成 idle ----
|
|
145
175
|
for (const b of blocks) {
|
|
146
|
-
|
|
176
|
+
// issue #32:chat 跟 quiet/fail 一样不算「做了什么」——聊天是她答他的话,
|
|
177
|
+
// 不是这一轮她自己起的动作。否则他在她醒着时随便接一句,这次 wake 就不算发呆了。
|
|
178
|
+
const done = b.actions.filter((a) => a.kind !== 'quiet' && a.kind !== 'fail' && a.kind !== 'chat')
|
|
147
179
|
b.idle = done.length === 0
|
|
148
180
|
}
|
|
149
|
-
|
|
181
|
+
// 只有真醒过的块才算「醒着但没做」:没有 wake: 的那块(quiet / 他说话时插的几句)
|
|
182
|
+
// 根本不是一次醒来。以前它被算进 idleWakes,于是 wakes=0 而 idleWakes=1。
|
|
183
|
+
counts.idleWakes = blocks.filter((b) => b.wake !== null && b.idle).length
|
|
150
184
|
|
|
151
185
|
// ---- 当前状态 ----
|
|
152
186
|
const lastAct = today.length ? today[today.length - 1] : null
|
|
@@ -170,6 +204,10 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
170
204
|
} else if (recent && lastKind === 'say') {
|
|
171
205
|
phase = 'talking'; label = '在想怎么回你'
|
|
172
206
|
detail = activeBlock.actions[activeBlock.actions.length - 1].text
|
|
207
|
+
} else if (recent && lastKind === 'chat') {
|
|
208
|
+
// 刚跟他聊过几句:还是「在说话」,只是这些行不算她主动做的事(issue #32)。
|
|
209
|
+
phase = 'talking'; label = '在想怎么回你'
|
|
210
|
+
detail = ''
|
|
173
211
|
} else if (recent && lastKind === 'diary') {
|
|
174
212
|
phase = 'writing'; label = '在写日记'
|
|
175
213
|
detail = ''
|
|
@@ -191,7 +229,9 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
191
229
|
const mins = plannedWakeAt ? Math.round((plannedWakeAt - nowMs) / 60_000) : null
|
|
192
230
|
detail = plannedWakeAt && plannedWakeAt > nowMs
|
|
193
231
|
? `约 ${new Date(plannedWakeAt).toTimeString().slice(0, 5)} 会睡醒${mins !== null && mins >= 0 ? `(还有 ${mins} 分钟)` : ''}`
|
|
194
|
-
:
|
|
232
|
+
: (quietToday
|
|
233
|
+
? '到点了,暂时没有可出的事件,还没醒'
|
|
234
|
+
: '今天已经到点,但还没醒')
|
|
195
235
|
} else {
|
|
196
236
|
phase = 'asleep'; label = '沉在水底'
|
|
197
237
|
detail = plannedWakeAt && plannedWakeAt > nowMs
|
|
@@ -213,6 +253,16 @@ export function deriveStatus({ events = [], config = {}, now = new Date() } = {}
|
|
|
213
253
|
const timeline = []
|
|
214
254
|
for (const b of blocks) {
|
|
215
255
|
const hhmm = new Date(b.at).toTimeString().slice(0, 5)
|
|
256
|
+
// 没有 wake: 的块不属于任何一次醒来(orphan)。以前拿当天第一条事件的时间
|
|
257
|
+
// 当块头,于是一天没醒过也会冒出「06:15 醒了,在发呆」(0.9.1 反馈)。
|
|
258
|
+
// 这些动作照原样列出来就好,别替她补一句「醒了」。
|
|
259
|
+
if (b.wake === null) {
|
|
260
|
+
for (const a of b.actions) {
|
|
261
|
+
const h2 = new Date(a.at).toTimeString().slice(0, 5)
|
|
262
|
+
timeline.push({ at: new Date(a.at).toISOString(), kind: a.kind, text: `${h2} ${a.text}` })
|
|
263
|
+
}
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
216
266
|
if (b.idle) {
|
|
217
267
|
timeline.push({ at: new Date(b.at).toISOString(), kind: 'idle', text: `${hhmm} 醒了,在发呆`, idle: true })
|
|
218
268
|
// 「醒来失败」「没有该问的事」正是这一块为什么什么都没做的原因,必须留下来,
|
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
|
@@ -54,12 +54,17 @@ import { refreshSelfDoc } from './selfdoc.js'
|
|
|
54
54
|
import { growthDay, stageOf } from './derive.js'
|
|
55
55
|
import { hearTurn } from './hear.js'
|
|
56
56
|
import { stripToolMarkup, hasToolMarkup } from './markup.js'
|
|
57
|
+
import { clearWakeSectionFor } from './identity.js'
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* 我们自己 prompt 进去的消息,rpcId 会带这些前缀。
|
|
60
61
|
* 只是"多一道保险":真正的判据是 clientTimeZone(见 classifyMessage)。
|
|
62
|
+
*
|
|
63
|
+
* `carry` 是换会话前请她留话那条(home.js 的 `carry-ask-`)。少了它,那条
|
|
64
|
+
* 消息会掉进 `kind === 'user'` 且没有 clientTimeZone 的 `unknown` 分支:不进
|
|
65
|
+
* 轮数(行为正确),但每换一次会话就往日志里多一行「认不出来源」(2026-11-06)。
|
|
61
66
|
*/
|
|
62
|
-
const PLUGIN_RPC = /^(wake|say|boot|report|letter|sleep|nudge|probe|turn|sys)[-_]/i
|
|
67
|
+
const PLUGIN_RPC = /^(wake|say|boot|report|letter|sleep|nudge|probe|turn|sys|carry)[-_]/i
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
70
|
* 宿主/我们这边自己拼的、**确定不是人打的** source.kind。
|
|
@@ -236,6 +241,9 @@ function cut(s) {
|
|
|
236
241
|
return t.length > TURN_TEXT_MAX ? t.slice(0, TURN_TEXT_MAX) + '…' : t
|
|
237
242
|
}
|
|
238
243
|
|
|
244
|
+
/** 已经挂过观察者的宿主上下文(同一进程只挂一次,见 registerTurns) */
|
|
245
|
+
const TURNS_BOUND = new WeakSet()
|
|
246
|
+
|
|
239
247
|
/**
|
|
240
248
|
* 注册"侧栏轮数"观察者。
|
|
241
249
|
*
|
|
@@ -248,6 +256,30 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
248
256
|
const log = deps.log ?? (() => {})
|
|
249
257
|
if (typeof ctx?.on !== 'function') return () => {}
|
|
250
258
|
|
|
259
|
+
/**
|
|
260
|
+
* 2026-11-06(外部日志:被占用的实例照样往共享事件流里写 `seen:`)。
|
|
261
|
+
*
|
|
262
|
+
* 观测者以前是**无条件**写盘的:它看到「他来过」就 appendEvent,从不过问这个
|
|
263
|
+
* 家是不是自己的。于是同一个数据目录被两个实例开着时,只读那一侧也在写——
|
|
264
|
+
* 日志里那三条同一毫秒的 `seen:` 就是两个写者各写一遍。
|
|
265
|
+
*
|
|
266
|
+
* 这里和工具侧用同一道闸(tenant.js 的 guardWrite):房主身份 ok===false 时
|
|
267
|
+
* 一句都不写。tenant 是**活引用**(占用权丢了会原地改写成 ok:false),
|
|
268
|
+
* 所以必须在每次写之前现查,不能在注册时查一次就完。
|
|
269
|
+
*/
|
|
270
|
+
const tenant = deps.tenant ?? null
|
|
271
|
+
const writable = () => !(tenant && tenant.ok === false)
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 同一份宿主上下文只挂一次。
|
|
275
|
+
*
|
|
276
|
+
* 2026-11-06:插件被同一个进程加载两遍时(两个入口 / bundles 里重复),
|
|
277
|
+
* 两个观察者各有各的节流记忆,同一条人话会写出两条同一毫秒的 `seen:`——
|
|
278
|
+
* 记账行一重复,事件流就再也不能当证据用。第二次注册直接交白卷。
|
|
279
|
+
*/
|
|
280
|
+
if (TURNS_BOUND.has(ctx)) return () => {}
|
|
281
|
+
TURNS_BOUND.add(ctx)
|
|
282
|
+
|
|
251
283
|
/** 正在等 turn/end 的那一轮:{ sessionId, msgs:[], sinceId, at } */
|
|
252
284
|
let pending = null
|
|
253
285
|
/** 正在异步认来源的那条人话(见 takeTurnEnd:事件跟得太紧时要等它) */
|
|
@@ -285,6 +317,8 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
285
317
|
* 非 HER_TYPES 的类型,写成 tool 会被 schema 悄悄拒掉。
|
|
286
318
|
*/
|
|
287
319
|
async function markSeen() {
|
|
320
|
+
// 2026-11-06:写盘之前先过所有权闸门(被占用的实例一条都不许写)。
|
|
321
|
+
if (!writable()) return
|
|
288
322
|
lastSeenAt = Date.now()
|
|
289
323
|
await appendEvent(
|
|
290
324
|
{ type: 'meta', text: `seen:${new Date(lastSeenAt).toISOString()}` },
|
|
@@ -297,6 +331,16 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
297
331
|
|
|
298
332
|
/** 收到真人消息:只认"她住的那个会话",并把这一轮开始前的事件 id 记下来 */
|
|
299
333
|
async function onHuman(sessionId, data) {
|
|
334
|
+
if (!writable()) return
|
|
335
|
+
// 2026-11-06(用户报「叫醒之后她就不说话了」):他一开口,上一次叫醒留下的
|
|
336
|
+
// 「处境」当场作废。
|
|
337
|
+
//
|
|
338
|
+
// 处境原本只该出现在**她自己醒的那一轮**里,靠 turn/end 撤。但 turn/end 不是
|
|
339
|
+
// 一定能认到(监听按会话挂、重启会丢、两个渠道挨得近时也会错号),那一段
|
|
340
|
+
// 「这一轮是你自己的,不用回应任何人;不想说就什么都别写」就留在 system prompt
|
|
341
|
+
// 里,接下来他说的每一句都带着它——她就只思考、不开口,而且连续多轮都这样。
|
|
342
|
+
// 人真的说话了,这段就不该还在;清得比 turn/end 早一步也不会出错。
|
|
343
|
+
try { clearWakeSectionFor(sessionId) } catch { /* ignore */ }
|
|
300
344
|
const home = await homeStatus(root)
|
|
301
345
|
if (!home.sessionId) return
|
|
302
346
|
if (home.sessionId !== sessionId) {
|
|
@@ -381,7 +425,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
381
425
|
|
|
382
426
|
/** turn/end:结账 */
|
|
383
427
|
async function settle(p) {
|
|
384
|
-
if (stopped) return
|
|
428
|
+
if (stopped || !writable()) return
|
|
385
429
|
if (busy) {
|
|
386
430
|
log('turns · 上一轮还没结完,这次不记')
|
|
387
431
|
return
|
|
@@ -576,6 +620,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
576
620
|
return () => {
|
|
577
621
|
stopped = true
|
|
578
622
|
pending = null
|
|
623
|
+
TURNS_BOUND.delete(ctx)
|
|
579
624
|
try {
|
|
580
625
|
if (typeof off === 'function') off()
|
|
581
626
|
} catch {
|
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.2",
|
|
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": {
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* 把老的 `
|
|
3
|
+
* 把老的 `dsh-fish` 安装收拾到新包名(@ssamurias/dsh-fish)。
|
|
4
4
|
*
|
|
5
5
|
* 为什么需要它:0.8.10 起插件包名从 `dsh-fish` 改成 `@ssamurias/dsh-fish`,
|
|
6
6
|
* 插件自带 cordis.patch.yml 里的条目名也跟着变成完整包名;而老 profile 的
|
|
7
7
|
* node_modules 里只有裸名链接 `dsh-fish` → 条目 import 失败 → **宿主直接启动崩溃**。
|
|
8
8
|
* 升级前跑一遍这个脚本就能避免;已经崩了也能跑(它只改 profile 文件,不需要应用启动)。
|
|
9
9
|
*
|
|
10
|
+
* 0.9.1 修(issue #43):原来只认 `link:` / `file:` 两种依赖写法。可是按更新说明用
|
|
11
|
+
* `dsh plugin add "@ssamurias/dsh-fish@0.9.0"` 升级的人,profile 里留下的是**版本号**,
|
|
12
|
+
* 脚本一句「没有老依赖,跳过」就退出 0 —— 旧键 `dsh-fish` 与旧 bundles 条目原封不动,
|
|
13
|
+
* 下次 pnpm 清理时两条 fish 打架。现在三种写法都收拾:源码目录、tgz、版本号/远端。
|
|
14
|
+
*
|
|
10
15
|
* 用法(先关掉 DeepSeek Harness):
|
|
11
16
|
* node scripts/fix-legacy-link.mjs # 预览
|
|
12
17
|
* node scripts/fix-legacy-link.mjs --write # 真的改
|
|
@@ -15,31 +20,14 @@
|
|
|
15
20
|
import fs from 'node:fs'
|
|
16
21
|
import path from 'node:path'
|
|
17
22
|
import os from 'node:os'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
18
24
|
|
|
19
|
-
const NEW = '@ssamurias/dsh-fish'
|
|
20
|
-
const LEGACY = 'dsh-fish'
|
|
21
|
-
const args = process.argv.slice(2)
|
|
22
|
-
const WRITE = args.includes('--write')
|
|
23
|
-
const pIdx = args.indexOf('--profile')
|
|
24
|
-
const onlyProfile = pIdx >= 0 ? args[pIdx + 1] : ''
|
|
25
|
-
|
|
26
|
-
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
|
27
|
-
const profilesRoot = path.join(os.homedir(), '.dsh', 'profiles')
|
|
25
|
+
export const NEW = '@ssamurias/dsh-fish'
|
|
26
|
+
export const LEGACY = 'dsh-fish'
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
if (onlyProfile) return [path.resolve(onlyProfile)]
|
|
31
|
-
try {
|
|
32
|
-
return fs
|
|
33
|
-
.readdirSync(profilesRoot, { withFileTypes: true })
|
|
34
|
-
.filter((d) => d.isDirectory())
|
|
35
|
-
.map((d) => path.join(profilesRoot, d.name))
|
|
36
|
-
.filter((p) => fs.existsSync(path.join(p, 'package.json')))
|
|
37
|
-
} catch {
|
|
38
|
-
return []
|
|
39
|
-
}
|
|
40
|
-
}
|
|
28
|
+
const SPEC_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies']
|
|
41
29
|
|
|
42
|
-
function readJson(p) {
|
|
30
|
+
export function readJson(p) {
|
|
43
31
|
try {
|
|
44
32
|
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
45
33
|
} catch {
|
|
@@ -47,101 +35,220 @@ function readJson(p) {
|
|
|
47
35
|
}
|
|
48
36
|
}
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
|
|
38
|
+
/** profile 里那条老依赖:字段名 + 原样的 spec。没有就 null。 */
|
|
39
|
+
export function specOf(pkg) {
|
|
40
|
+
for (const field of SPEC_FIELDS) {
|
|
52
41
|
const spec = pkg?.[field]?.[LEGACY]
|
|
53
|
-
if (typeof spec === 'string' &&
|
|
42
|
+
if (typeof spec === 'string' && spec.trim()) return { field, spec: spec.trim() }
|
|
54
43
|
}
|
|
55
44
|
return null
|
|
56
45
|
}
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
/**
|
|
48
|
+
* 这条 spec 指向什么?
|
|
49
|
+
* dir —— `link:` / `file:` 指向目录(源码目录安装),要建 junction
|
|
50
|
+
* tarball —— `file:` 指向 .tgz(0.8.x 的临时包装法),只清账、不建链接
|
|
51
|
+
* spec —— 版本号 / `^1.2.3` / `github:...`,只清账
|
|
52
|
+
*/
|
|
53
|
+
export function specKind(spec) {
|
|
54
|
+
const s = String(spec ?? '').trim()
|
|
55
|
+
if (!/^(link:|file:)/i.test(s)) return 'spec'
|
|
56
|
+
return /\.(tgz|tar\.gz)$/i.test(s) ? 'tarball' : 'dir'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function targetOf(profileDir, spec) {
|
|
60
|
+
const raw = String(spec).replace(/^(link:|file:)/i, '').trim()
|
|
60
61
|
if (!raw) return ''
|
|
61
62
|
if (/^[A-Za-z]:[\\/]/.test(raw) || path.isAbsolute(raw)) return path.resolve(raw)
|
|
62
63
|
return path.resolve(profileDir, raw)
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
function linkName(profileDir, name) {
|
|
66
|
+
export function linkName(profileDir, name) {
|
|
66
67
|
return path.join(profileDir, 'node_modules', ...name.split('/'))
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
export function bundlesOf(pkg) {
|
|
71
|
+
const prof = pkg?.dsh?.profile ?? (pkg?.dsh && Array.isArray(pkg.dsh.bundles) ? pkg.dsh : null)
|
|
72
|
+
return prof && Array.isArray(prof.bundles) ? prof.bundles : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 把老依赖从依赖表里清掉;新包名已经在装的话顺手对齐 bundles。 */
|
|
76
|
+
export function cleanDepKey(pkg, found, newName) {
|
|
77
|
+
const done = []
|
|
78
|
+
delete pkg[found.field][LEGACY]
|
|
79
|
+
done.push(`删掉 ${found.field}["${LEGACY}"]`)
|
|
80
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
81
|
+
if (deps[newName]) {
|
|
82
|
+
const bundles = bundlesOf(pkg)
|
|
83
|
+
if (bundles && !bundles.includes(newName)) {
|
|
84
|
+
bundles.push(newName)
|
|
85
|
+
done.push(`bundles 补上 ${newName}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return done
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function dropLegacyBundle(pkg, newName) {
|
|
92
|
+
const bundles = bundlesOf(pkg)
|
|
93
|
+
if (!bundles) return []
|
|
94
|
+
let n = 0
|
|
95
|
+
for (let i = bundles.length - 1; i >= 0; i -= 1) {
|
|
96
|
+
if (bundles[i] === LEGACY) {
|
|
97
|
+
bundles.splice(i, 1)
|
|
98
|
+
n += 1
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const done = n ? [`bundles 删掉 ${LEGACY} ×${n}`] : []
|
|
102
|
+
if (newName && !bundles.includes(newName)) {
|
|
103
|
+
bundles.push(newName)
|
|
104
|
+
done.push(`bundles 补上 ${newName}`)
|
|
105
|
+
}
|
|
106
|
+
return done
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function retireOldLink(profileDir, tag, done = []) {
|
|
110
|
+
const oldLink = linkName(profileDir, LEGACY)
|
|
111
|
+
if (!fs.existsSync(oldLink)) return done
|
|
112
|
+
const off = `${oldLink}.disabled-${tag}`
|
|
113
|
+
try {
|
|
114
|
+
fs.renameSync(oldLink, off)
|
|
115
|
+
done.push(`node_modules/${LEGACY} 改名停用`)
|
|
116
|
+
} catch (e) {
|
|
117
|
+
done.push(`node_modules/${LEGACY} 改名失败:${e?.code ?? e?.message ?? e}`)
|
|
118
|
+
}
|
|
119
|
+
return done
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 收拾一个 profile。返回 { changed, done, notes }。
|
|
124
|
+
* `write=false` 时只算要做什么,不落盘。
|
|
125
|
+
*/
|
|
126
|
+
export function migrateOne(profileDir, { write = false, log = (s) => console.log(s), tag = '' } = {}) {
|
|
127
|
+
const pkgPath = path.join(profileDir, 'package.json')
|
|
72
128
|
const pkg = readJson(pkgPath)
|
|
73
|
-
|
|
129
|
+
const notes = []
|
|
130
|
+
const done = []
|
|
131
|
+
if (!pkg) {
|
|
132
|
+
log(`- ${profileDir}: 没有 package.json,跳过`)
|
|
133
|
+
return { changed: false, done, notes }
|
|
134
|
+
}
|
|
74
135
|
const found = specOf(pkg)
|
|
75
136
|
if (!found) {
|
|
76
|
-
|
|
77
|
-
|
|
137
|
+
log(`- ${profileDir}: 没有老的 ${LEGACY} 依赖,跳过`)
|
|
138
|
+
return { changed: false, done, notes }
|
|
78
139
|
}
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
140
|
+
const kind = specKind(found.spec)
|
|
141
|
+
const how = kind === 'dir' ? '源码目录' : kind === 'tarball' ? 'tgz 包' : '版本号/远端'
|
|
142
|
+
log(`\n== ${profileDir}`)
|
|
143
|
+
log(` 老依赖: ${found.field}["${LEGACY}"] = ${found.spec}(${how})`)
|
|
144
|
+
|
|
145
|
+
let newName = NEW
|
|
146
|
+
let target = ''
|
|
147
|
+
if (kind === 'dir') {
|
|
148
|
+
target = targetOf(profileDir, found.spec)
|
|
149
|
+
const srcPkg = readJson(path.join(target, 'package.json'))
|
|
150
|
+
const srcName = String(srcPkg?.name ?? '')
|
|
151
|
+
log(` 源目录: ${target}`)
|
|
152
|
+
log(` 源包名: ${srcName || '(读不到)'}`)
|
|
153
|
+
if (!srcName) notes.push('源目录读不到 package.json,这条按"只清账"处理')
|
|
154
|
+
else if (srcName !== LEGACY && srcName !== NEW) log(` ! 源包名是 ${srcName},脚本按它迁移`)
|
|
155
|
+
if (srcName && srcName !== LEGACY) newName = srcName
|
|
89
156
|
}
|
|
90
|
-
if (srcName !== NEW) console.log(` ! 源包名是 ${srcName},脚本按它迁移`)
|
|
91
|
-
const newName = srcName === LEGACY ? NEW : srcName
|
|
92
157
|
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
|
|
158
|
+
if (!write) {
|
|
159
|
+
if (kind === 'dir' && target) {
|
|
160
|
+
log(` [预览] 会:建 node_modules/${newName} → 源目录;把依赖键改成 ${newName};删掉 ${LEGACY} 键与链接`)
|
|
161
|
+
} else {
|
|
162
|
+
log(` [预览] 会:删掉 ${found.field}["${LEGACY}"];bundles 里去掉 ${LEGACY};旧 node_modules/${LEGACY} 改名停用`)
|
|
163
|
+
}
|
|
164
|
+
return { changed: true, done, notes }
|
|
96
165
|
}
|
|
97
166
|
|
|
98
|
-
|
|
99
|
-
const bak = `${pkgPath}.bak-fishrename-${stamp}`
|
|
167
|
+
const bak = `${pkgPath}.bak-fishrename-${tag || 'now'}`
|
|
100
168
|
fs.copyFileSync(pkgPath, bak)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
169
|
+
log(` 备份: ${bak}`)
|
|
170
|
+
|
|
171
|
+
if (kind === 'dir' && target) {
|
|
172
|
+
const newLink = linkName(profileDir, newName)
|
|
173
|
+
fs.mkdirSync(path.dirname(newLink), { recursive: true })
|
|
174
|
+
if (!fs.existsSync(newLink)) {
|
|
175
|
+
try {
|
|
176
|
+
fs.symlinkSync(target, newLink, 'junction')
|
|
177
|
+
done.push(`建链 node_modules/${newName} → ${target}`)
|
|
178
|
+
} catch (e) {
|
|
179
|
+
done.push(`建链失败:${e?.message ?? e}(可手动 New-Item -ItemType Junction)`)
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
done.push(`链接已存在:${newName}`)
|
|
112
183
|
}
|
|
113
|
-
} else {
|
|
114
|
-
console.log(` 链接已存在: ${newLink}`)
|
|
115
184
|
}
|
|
116
185
|
|
|
117
|
-
// 3) 改依赖键
|
|
118
186
|
delete pkg[found.field][LEGACY]
|
|
119
|
-
pkg.dependencies
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
fs.renameSync(oldLink, off)
|
|
135
|
-
console.log(` 老链接停用: ${off}`)
|
|
136
|
-
} catch (e) {
|
|
137
|
-
console.log(` ! 老链接改名失败: ${e.message}`)
|
|
187
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
188
|
+
if (deps[newName]) {
|
|
189
|
+
// 新包名已经在依赖里(plugin add 装好了):只把 bundles 对齐,不动依赖表。
|
|
190
|
+
done.push(`删掉 ${found.field}["${LEGACY}"]`)
|
|
191
|
+
done.push(...dropLegacyBundle(pkg, newName))
|
|
192
|
+
} else if (kind === 'dir') {
|
|
193
|
+
// 源码目录:把依赖键改名指向同一条 spec。
|
|
194
|
+
pkg.dependencies = pkg.dependencies ?? {}
|
|
195
|
+
pkg.dependencies[newName] = found.spec
|
|
196
|
+
done.push(`依赖改为 ${found.field}["${newName}"]`)
|
|
197
|
+
const bundles = bundlesOf(pkg)
|
|
198
|
+
if (bundles) {
|
|
199
|
+
for (let i = 0; i < bundles.length; i += 1) if (bundles[i] === LEGACY) bundles[i] = newName
|
|
200
|
+
if (!bundles.includes(newName)) bundles.push(newName)
|
|
201
|
+
done.push(`bundles 对齐 ${newName}`)
|
|
138
202
|
}
|
|
203
|
+
} else {
|
|
204
|
+
done.push(`删掉 ${found.field}["${LEGACY}"]`)
|
|
205
|
+
done.push(...dropLegacyBundle(pkg, ''))
|
|
206
|
+
notes.push('新包名不在依赖里:只清账,装不装得上取决于这次 add 是否成功')
|
|
207
|
+
}
|
|
208
|
+
retireOldLink(profileDir, tag || 'now', done)
|
|
209
|
+
|
|
210
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
|
|
211
|
+
done.push('写入 package.json')
|
|
212
|
+
return { changed: true, done, notes }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function profileDirs(onlyProfile = '') {
|
|
216
|
+
if (onlyProfile) return [path.resolve(onlyProfile)]
|
|
217
|
+
const profilesRoot = path.join(os.homedir(), '.dsh', 'profiles')
|
|
218
|
+
try {
|
|
219
|
+
return fs
|
|
220
|
+
.readdirSync(profilesRoot, { withFileTypes: true })
|
|
221
|
+
.filter((d) => d.isDirectory())
|
|
222
|
+
.map((d) => path.join(profilesRoot, d.name))
|
|
223
|
+
.filter((p) => fs.existsSync(path.join(p, 'package.json')))
|
|
224
|
+
} catch {
|
|
225
|
+
return []
|
|
139
226
|
}
|
|
140
|
-
touched++
|
|
141
227
|
}
|
|
142
228
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
229
|
+
function main() {
|
|
230
|
+
const args = process.argv.slice(2)
|
|
231
|
+
const write = args.includes('--write')
|
|
232
|
+
const pIdx = args.indexOf('--profile')
|
|
233
|
+
const onlyProfile = pIdx >= 0 ? args[pIdx + 1] : ''
|
|
234
|
+
const tag = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
235
|
+
let touched = 0
|
|
236
|
+
for (const dir of profileDirs(onlyProfile)) {
|
|
237
|
+
const r = migrateOne(dir, { write, tag })
|
|
238
|
+
for (const n of r.notes) console.log(` 注: ${n}`)
|
|
239
|
+
for (const d of r.done) console.log(` · ${d}`)
|
|
240
|
+
if (write && r.changed) touched += 1
|
|
241
|
+
}
|
|
242
|
+
if (!write) console.log('\n这是预览。确认无误后加 --write 再跑一次(记得先关应用)。')
|
|
243
|
+
else console.log(`\n改完 ${touched} 个 profile。现在启动 DeepSeek Harness。`)
|
|
147
244
|
}
|
|
245
|
+
|
|
246
|
+
const invokedDirectly = (() => {
|
|
247
|
+
try {
|
|
248
|
+
return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
249
|
+
} catch {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
})()
|
|
253
|
+
|
|
254
|
+
if (invokedDirectly) main()
|