@ilaomianyang/dsh-memory-graph 0.10.4 → 0.12.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/src/turns.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Turn assembly: live events and a replayed session file go through one reducer.
3
+ *
4
+ * Two things must never be implemented twice. First, what counts as a message a
5
+ * person actually typed — the harness pushes runtime snapshots, skill catalogs,
6
+ * sampled time and compaction checkpoints down the same channel, sometimes
7
+ * tagged `user`. Second, the truncation and redaction applied before anything
8
+ * touches disk. If the live listener and the history importer had separate
9
+ * copies of either, they would drift and the drift would be silent: a leaked
10
+ * snapshot on one side, a lost message on the other.
11
+ *
12
+ * So the filter lives here. The callers only decide *when* a turn is closed and
13
+ * *what timestamp* its episode carries.
14
+ */
15
+ import { nowIso } from './vault.js'
16
+
17
+ /** Assistant text is context, not the record; a handful of parts is plenty. */
18
+ const MAX_ASSISTANT_PARTS = 6
19
+
20
+ function collectText(node, depth = 0, seen = new Set(), out = []) {
21
+ if (node === null || node === undefined || depth > 4) return out
22
+ if (typeof node === 'string') return out
23
+ if (Array.isArray(node)) {
24
+ for (const item of node) collectText(item, depth + 1, seen, out)
25
+ return out
26
+ }
27
+ if (typeof node !== 'object') return out
28
+ if (seen.has(node)) return out
29
+ seen.add(node)
30
+
31
+ if (node.type === 'text' && typeof node.text === 'string') {
32
+ out.push(node.text)
33
+ return out
34
+ }
35
+ for (const key of ['content', 'message', 'text', 'parts', 'blocks']) {
36
+ if (key in node) collectText(node[key], depth + 1, seen, out)
37
+ }
38
+ return out
39
+ }
40
+
41
+ export function eventText(event) {
42
+ const parts = collectText(event?.data)
43
+ return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim()
44
+ }
45
+
46
+ export function truncate(text, max) {
47
+ const value = String(text ?? '').trim()
48
+ if (value.length <= max) return value
49
+ return `${value.slice(0, max)}…`
50
+ }
51
+
52
+ /**
53
+ * Delimited harness blocks come off before any pattern is consulted.
54
+ *
55
+ * They are the one kind of injection whose extent is knowable, so removing them
56
+ * keeps the person's own words when a real message happens to carry one — the
57
+ * alternative (dropping the whole message) loses the part that was worth
58
+ * keeping. Undelimited blocks (a runtime snapshot) are handled by the patterns.
59
+ */
60
+ export function stripInjectedBlocks(text) {
61
+ return String(text ?? '')
62
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
63
+ .replace(/[ \t]+\n/g, '\n')
64
+ .replace(/\n{3,}/g, '\n\n')
65
+ .trim()
66
+ }
67
+
68
+ /** The project a session belongs to: the last segment of the cwd it started in. */
69
+ export function projectOf(session, fallback) {
70
+ const cwd = session?.header?.cwd ?? session?.meta?.cwd ?? session?.cwd
71
+ if (typeof cwd === 'string' && cwd.trim() !== '') {
72
+ // Both separators, whatever platform this is running on: the path comes from
73
+ // the session header, which may well have been written on the other OS.
74
+ const parts = cwd.replace(/[\\/]+$/, '').split(/[\\/]/)
75
+ const base = parts[parts.length - 1] ?? ''
76
+ if (base !== '') return base
77
+ }
78
+ return fallback
79
+ }
80
+
81
+ /** The same shape as vault's nowIso(), for an arbitrary instant. */
82
+ export function isoAt(ms) {
83
+ const d = new Date(ms)
84
+ const pad = (n) => String(n).padStart(2, '0')
85
+ const offset = -d.getTimezoneOffset()
86
+ const sign = offset >= 0 ? '+' : '-'
87
+ const abs = Math.abs(offset)
88
+ return (
89
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
90
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
91
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Feed it events in order; it hands back finished episodes.
97
+ *
98
+ * `push()` returns an array because one event can close two turns: a user
99
+ * message that arrives while the previous turn never closed flushes that one
100
+ * first, then opens the next.
101
+ */
102
+ export function createTurnAssembler({ cfg, redactor }) {
103
+ const buffers = new Map()
104
+
105
+ // Both the switch and the skip list are editable while the plugin runs (they
106
+ // are `.volatile()` in the schema), so neither is baked in here: the switch is
107
+ // read per event, and the compiled rules are rebuilt only when the list's text
108
+ // actually changes — a `new RegExp` on every token would be pure waste.
109
+ let skipKey
110
+ let skipRules = []
111
+
112
+ function compiledSkipRules() {
113
+ const list = cfg.capture.skipPatterns ?? []
114
+ const key = list.join('\u0000')
115
+ if (key !== skipKey) {
116
+ skipKey = key
117
+ skipRules = list.flatMap((pattern) => {
118
+ try {
119
+ return [new RegExp(pattern, 'i')]
120
+ } catch {
121
+ return []
122
+ }
123
+ })
124
+ }
125
+ return skipRules
126
+ }
127
+
128
+ /** True when the "user" side is really a harness injection, not a person typing. */
129
+ const injected = (text) => text !== '' && compiledSkipRules().some((re) => re.test(text))
130
+
131
+ function openBuffer(sessionId, project) {
132
+ let buffer = buffers.get(sessionId)
133
+ if (!buffer) {
134
+ buffer = {
135
+ project: project ?? cfg.defaultProject,
136
+ user: '',
137
+ assistant: [],
138
+ tools: 0,
139
+ turn: null,
140
+ at: null,
141
+ }
142
+ buffers.set(sessionId, buffer)
143
+ }
144
+ return buffer
145
+ }
146
+
147
+ function finish(sessionId, at) {
148
+ const buffer = buffers.get(sessionId)
149
+ if (!buffer) return null
150
+ buffers.delete(sessionId)
151
+
152
+ const toolCount = buffer.tools
153
+ const user = truncate(buffer.user, cfg.capture.userChars)
154
+ const assistant = truncate(buffer.assistant.join('\n'), cfg.capture.assistantChars)
155
+
156
+ // Every episode is anchored to something the user actually said. A turn with
157
+ // no user side is context noise, and one too short to mean anything — such as
158
+ // "ok" — would only fill the vault with lines the extractor cannot use.
159
+ if (user === '' || user.length + assistant.length < cfg.capture.minChars) return null
160
+
161
+ // The turn's own clock when the log carries one (a replayed session, or a
162
+ // turn flushed a minute late), otherwise now: the timeline should say when
163
+ // it happened, not when it was written down.
164
+ const stamp = at ?? buffer.at
165
+ const when = typeof stamp === 'string' ? stamp : stamp === null || stamp === undefined ? nowIso() : isoAt(stamp)
166
+
167
+ return {
168
+ t: when,
169
+ project: buffer.project,
170
+ session: sessionId,
171
+ turn: buffer.turn,
172
+ user: redactor.redact(user).text,
173
+ assistant: redactor.redact(assistant).text,
174
+ ...(cfg.capture.includeToolOutput && toolCount > 0 ? { tools: toolCount } : {}),
175
+ }
176
+ }
177
+
178
+ return {
179
+ /**
180
+ * @param {{sessionId: string, project?: string, event: object}} input
181
+ * @returns {object[]} episodes that are finished and ready to append
182
+ */
183
+ push({ sessionId, project, event }) {
184
+ if (!cfg.capture.enabled) return []
185
+ const type = event?.type
186
+ const at = Number.isFinite(event?.time) ? event.time : null
187
+ const done = []
188
+
189
+ if (type === 'turn/end') {
190
+ const buffer = buffers.get(sessionId)
191
+ if (!buffer) return done
192
+ if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
193
+ const episode = finish(sessionId, at)
194
+ if (episode) done.push(episode)
195
+ return done
196
+ }
197
+
198
+ if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return done
199
+
200
+ if (type === 'user/message') {
201
+ // The harness delivers runtime snapshots, skill catalogs, sampled time,
202
+ // approval notices and compaction checkpoints through the same channel as
203
+ // the person typing — and not always with a telling `source.kind`: a real
204
+ // runtime snapshot has arrived here tagged `user` (it was captured as if
205
+ // the person had typed it). So provenance can only *add* a skip, never
206
+ // veto the patterns; both are consulted, and delimited blocks come off
207
+ // first so a message that merely carried a reminder keeps its own words.
208
+ const kind = event?.data?.source?.kind
209
+ const text = stripInjectedBlocks(eventText(event))
210
+ const synthetic = (typeof kind === 'string' && kind !== 'user') || text === '' || injected(text)
211
+ if (synthetic) return done
212
+
213
+ const open = buffers.get(sessionId)
214
+ if (open && (open.user !== '' || open.assistant.length > 0)) {
215
+ // A turn that never closed (interrupted, or an older harness): keep it.
216
+ const episode = finish(sessionId, at)
217
+ if (episode) done.push(episode)
218
+ }
219
+
220
+ const buffer = openBuffer(sessionId, project)
221
+ buffer.user = text
222
+ buffer.turn = typeof event?.data?.turn === 'number' ? event.data.turn : buffer.turn
223
+ if (at !== null) buffer.at = at
224
+ return done
225
+ }
226
+
227
+ const buffer = openBuffer(sessionId, project)
228
+ if (at !== null) buffer.at = at
229
+
230
+ if (type === 'assistant/message') {
231
+ const text = eventText(event)
232
+ if (text !== '' && buffer.assistant.length < MAX_ASSISTANT_PARTS) buffer.assistant.push(text)
233
+ if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
234
+ return done
235
+ }
236
+
237
+ buffer.tools += 1
238
+ return done
239
+ },
240
+
241
+ /** Close one session's open turn. Returns the episode, or null if it was noise. */
242
+ flush(sessionId, at) {
243
+ return finish(sessionId, at ?? undefined)
244
+ },
245
+
246
+ /** Close every open turn (shutdown, or the end of a replayed file). */
247
+ flushAll(at) {
248
+ const out = []
249
+ for (const sessionId of [...buffers.keys()]) {
250
+ const episode = finish(sessionId, at ?? undefined)
251
+ if (episode) out.push(episode)
252
+ }
253
+ return out
254
+ },
255
+
256
+ /** True when this session has an open turn — a hint for the timer bookkeeping. */
257
+ isOpen(sessionId) {
258
+ return buffers.has(sessionId)
259
+ },
260
+
261
+ forget(sessionId) {
262
+ buffers.delete(sessionId)
263
+ },
264
+ }
265
+ }
package/src/update.js ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Self-update: notice that a newer version exists, then let one decision install it.
3
+ *
4
+ * Two halves, deliberately separated.
5
+ *
6
+ * *Checking* is a single `GET <registry>/<name>/latest` — no query string, no
7
+ * body, nothing about the vault or the user in it — compared against the version
8
+ * this module is running. The answer is cached in the vault (`.update-state.json`)
9
+ * for `update.checkEveryHours`, so a page load does not turn into a request storm
10
+ * and an offline machine just keeps the last answer.
11
+ *
12
+ * *Installing* goes through the host's own `pluginManager.installBundle`, the same
13
+ * pnpm path `dsh plugin` uses, so the plugin does not invent a second way to move
14
+ * packages around and the profile's registry configuration is respected.
15
+ *
16
+ * The one thing that must not happen is a development checkout updating itself:
17
+ * under a `link:` install "install the newest version" would replace the user's
18
+ * working tree with a registry tarball. That is refused, and the refusal is
19
+ * reported as a fact about the installation rather than as an error.
20
+ */
21
+ import fsp from 'node:fs/promises'
22
+
23
+ import { paths } from './config.js'
24
+
25
+ export const UPDATE_PATH = '/api/memory-graph.update'
26
+ export const PACKAGE_NAME = '@ilaomianyang/dsh-memory-graph'
27
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
28
+ const REQUEST_TIMEOUT_MS = 8000
29
+ /** A GET younger than this is answered from the cache even when the caller asks. */
30
+ const FRESH_MS = 60 * 1000
31
+
32
+ /**
33
+ * The version this code is, read from its own manifest.
34
+ *
35
+ * Not a constant: a release bump must not need a second place to be edited, and
36
+ * the file is right there next to `src/`. A missing or unreadable manifest is not
37
+ * fatal — the plugin keeps working and simply cannot compare versions.
38
+ */
39
+ export async function readOwnVersion(url = new URL('../package.json', import.meta.url)) {
40
+ try {
41
+ const manifest = JSON.parse(await fsp.readFile(url, 'utf8'))
42
+ return typeof manifest?.version === 'string' ? manifest.version : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Compare two dotted versions: -1, 0, 1.
50
+ *
51
+ * Deliberately small rather than a semver dependency: the only consumers are a
52
+ * published version and a registry dist-tag, both plain `x.y.z`. A prerelease
53
+ * (`1.0.0-beta.1`) sorts below its release, which is the one rule worth keeping.
54
+ */
55
+ export function compareVersions(left, right) {
56
+ const parse = (text) => {
57
+ const [core, pre = ''] = String(text ?? '').trim().replace(/^v/u, '').split('-', 2)
58
+ const nums = core.split('.').map((part) => {
59
+ const value = Number.parseInt(part, 10)
60
+ return Number.isFinite(value) ? value : 0
61
+ })
62
+ while (nums.length < 3) nums.push(0)
63
+ return { nums: nums.slice(0, 3), pre }
64
+ }
65
+ const a = parse(left)
66
+ const b = parse(right)
67
+ for (let i = 0; i < 3; i += 1) {
68
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] < b.nums[i] ? -1 : 1
69
+ }
70
+ if (a.pre === b.pre) return 0
71
+ // A prerelease is older than the release it precedes; two prereleases compare
72
+ // as text, which is wrong in theory and unreachable for this package.
73
+ if (a.pre === '') return 1
74
+ if (b.pre === '') return -1
75
+ return a.pre < b.pre ? -1 : 1
76
+ }
77
+
78
+ /**
79
+ * What the registry says the newest version is.
80
+ *
81
+ * `latest` as a dist-tag, not "the highest version ever published": a dist-tag is
82
+ * what `npm install` would give you, and it is what a person means by "the newest
83
+ * version".
84
+ */
85
+ export async function fetchLatestVersion({ registry, name = PACKAGE_NAME, timeoutMs = REQUEST_TIMEOUT_MS, fetchFn } = {}) {
86
+ const base = String(registry || DEFAULT_REGISTRY).replace(/\/+$/u, '')
87
+ const url = `${base}/${name.replace('/', '%2f')}/latest`
88
+ const doFetch = fetchFn ?? globalThis.fetch
89
+ if (typeof doFetch !== 'function') throw new Error('这个运行时没有 fetch,查不了新版本 · fetch is unavailable in this runtime')
90
+ const init = { headers: { accept: 'application/json' } }
91
+ if (typeof AbortSignal?.timeout === 'function') init.signal = AbortSignal.timeout(timeoutMs)
92
+ const response = await doFetch(url, init)
93
+ if (!response?.ok) throw new Error(`registry ${base} answered ${response?.status ?? 'nothing'} · 注册表 ${base} 返回 ${response?.status ?? '无响应'}`)
94
+ const body = await response.json()
95
+ const version = typeof body?.version === 'string' ? body.version : ''
96
+ if (version === '') throw new Error(`registry ${base} returned no version · 注册表 ${base} 没有返回版本号`)
97
+ return { version, registry: base, name: body?.name ?? name }
98
+ }
99
+
100
+ function isoAt(ms) {
101
+ const date = new Date(ms)
102
+ const pad = (value) => String(Math.abs(value)).padStart(2, '0')
103
+ const offset = -date.getTimezoneOffset()
104
+ const sign = offset >= 0 ? '+' : '-'
105
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`
106
+ }
107
+
108
+ /**
109
+ * How this plugin is installed, as far as the update decision is concerned.
110
+ *
111
+ * `link:` and `file:` are a working copy (this repository, in development): a
112
+ * registry install would overwrite it, so an update is refused rather than
113
+ * attempted. Anything else — including "cannot tell" — is treated the same way:
114
+ * not updatable until the host itself says the package came from a registry.
115
+ */
116
+ export function installKindOf(bundles, name = PACKAGE_NAME) {
117
+ if (!Array.isArray(bundles)) return 'unknown'
118
+ const mine = bundles.find((bundle) => bundle?.name === name)
119
+ if (!mine) return 'unknown'
120
+ const spec = String(mine?.installTarget?.spec ?? '')
121
+ const source = String(mine?.source ?? '')
122
+ if (/^(link|file|workspace):/u.test(spec) || /^(link|file|workspace):/u.test(source)) return 'link'
123
+ if (mine.availability === 'profile' && spec !== '') return 'registry'
124
+ return 'unknown'
125
+ }
126
+
127
+ /**
128
+ * The checker. One instance per activation, shared by the route, the startup
129
+ * check and `memory_stats`.
130
+ */
131
+ export function createUpdateChecker({ cfg, log = () => {}, getPluginManager, getName = () => PACKAGE_NAME, now = () => Date.now(), readVersion = readOwnVersion, fetchFn } = {}) {
132
+ let current = null
133
+ let cached = null
134
+ let busy = false
135
+ let lastError = null
136
+ let loading = null
137
+
138
+ const settings = () => cfg?.update ?? {}
139
+ const stateFile = () => (cfg?.vaultDir ? paths(String(cfg.vaultDir)).updateState : null)
140
+
141
+ async function version() {
142
+ if (current === null) current = await readVersion()
143
+ return current
144
+ }
145
+
146
+ async function loadCache() {
147
+ if (cached !== null) return cached
148
+ cached = { checkedAt: null, latest: null, registry: null, error: null }
149
+ const file = stateFile()
150
+ if (file === null) return cached
151
+ try {
152
+ const parsed = JSON.parse(await fsp.readFile(file, 'utf8'))
153
+ const at = Number(parsed?.checkedAtMs)
154
+ cached = {
155
+ checkedAt: Number.isFinite(at) && at > 0 ? at : null,
156
+ latest: typeof parsed?.latest === 'string' ? parsed.latest : null,
157
+ registry: typeof parsed?.registry === 'string' ? parsed.registry : null,
158
+ error: typeof parsed?.error === 'string' ? parsed.error : null,
159
+ }
160
+ } catch {
161
+ // A missing or broken cache is the normal first run, not a failure.
162
+ }
163
+ return cached
164
+ }
165
+
166
+ async function saveCache(next) {
167
+ cached = next
168
+ const file = stateFile()
169
+ if (file === null) return
170
+ try {
171
+ await fsp.mkdir(String(cfg.vaultDir), { recursive: true })
172
+ await fsp.writeFile(
173
+ file,
174
+ `${JSON.stringify({ checkedAtMs: next.checkedAt, latest: next.latest, registry: next.registry, error: next.error }, null, 2)}\n`,
175
+ 'utf8',
176
+ )
177
+ } catch (error) {
178
+ log('warn', `自动更新:没能记下检查结果 · update: could not write the check cache: ${error?.message ?? error}`)
179
+ }
180
+ }
181
+
182
+ async function registryOf() {
183
+ const configured = String(settings().registry || '')
184
+ if (configured !== '') return configured
185
+ try {
186
+ const manager = getPluginManager?.()
187
+ const resolved = typeof manager?.registries === 'function' ? await manager.registries() : null
188
+ if (typeof resolved?.registry === 'string' && resolved.registry !== '') return resolved.registry
189
+ } catch {
190
+ // No plugin manager, or a profile that will not say: npm's own registry.
191
+ }
192
+ return DEFAULT_REGISTRY
193
+ }
194
+
195
+ async function refresh() {
196
+ const at = now()
197
+ const own = await version()
198
+ try {
199
+ const found = await fetchLatestVersion({ registry: await registryOf(), fetchFn })
200
+ const next = { checkedAt: at, latest: found.version, registry: found.registry, error: null }
201
+ await saveCache(next)
202
+ lastError = null
203
+ log('info', `自动更新:最新版本 ${found.version},当前 ${own ?? '未知'}`)
204
+ return next
205
+ } catch (error) {
206
+ // Keep the previous answer and its timestamp: an offline machine should not
207
+ // forget what it knew, and a failed check must not look like "up to date".
208
+ const previous = await loadCache()
209
+ lastError = String(error?.message ?? error)
210
+ await saveCache({ ...previous, error: lastError })
211
+ log('warn', `自动更新:查不到新版本 · update: could not reach the registry: ${lastError}`)
212
+ return { ...previous, error: lastError }
213
+ }
214
+ }
215
+
216
+ async function ensureFresh(force = false) {
217
+ const cache = await loadCache()
218
+ const ttl = Math.max(1, Number(settings().checkEveryHours ?? 12)) * 3600 * 1000
219
+ const age = cache.checkedAt === null ? Number.POSITIVE_INFINITY : now() - cache.checkedAt
220
+ if (force && age >= FRESH_MS) return refresh()
221
+ if (age < ttl) return cache
222
+ if (loading !== null) return loading
223
+ loading = refresh().finally(() => {
224
+ loading = null
225
+ })
226
+ return loading
227
+ }
228
+
229
+ async function installKind() {
230
+ try {
231
+ const manager = getPluginManager?.()
232
+ if (typeof manager?.listBundles !== 'function') return 'unknown'
233
+ return installKindOf(await manager.listBundles(), getName())
234
+ } catch {
235
+ return 'unknown'
236
+ }
237
+ }
238
+
239
+ /** The shape the Settings page and the tests read. */
240
+ async function status({ force = false } = {}) {
241
+ const own = await version()
242
+ if (settings().enabled === false) {
243
+ return {
244
+ ok: true,
245
+ current: own,
246
+ latest: null,
247
+ behind: false,
248
+ canCheck: false,
249
+ canInstall: false,
250
+ installKind: 'unknown',
251
+ checkedAt: null,
252
+ registry: await registryOf(),
253
+ busy,
254
+ message: '自动检查更新已在设置里关掉 · update checks are off',
255
+ error: null,
256
+ }
257
+ }
258
+ const cache = await ensureFresh(force)
259
+ const kind = await installKind()
260
+ const behind = own !== null && cache.latest !== null ? compareVersions(cache.latest, own) > 0 : false
261
+ const canInstall = behind && kind === 'registry' && !busy
262
+ let message = null
263
+ if (cache.error !== null && cache.latest === null) message = `查不到新版本 · could not check: ${cache.error}`
264
+ else if (cache.latest === null) message = null
265
+ else if (behind) message = `有新版本 ${cache.latest} · ${own ?? '?'} → ${cache.latest}`
266
+ else message = `已是最新版本 ${own ?? cache.latest}`
267
+ if (behind && kind === 'link') message = `${message};当前是本地 link: 安装,不能自动更新`
268
+ return {
269
+ ok: true,
270
+ current: own,
271
+ latest: cache.latest,
272
+ behind,
273
+ canCheck: true,
274
+ canInstall,
275
+ installKind: kind,
276
+ checkedAt: cache.checkedAt === null ? null : isoAt(cache.checkedAt),
277
+ registry: cache.registry ?? (await registryOf()),
278
+ busy,
279
+ message,
280
+ error: cache.error,
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Install the newest version through the host.
286
+ *
287
+ * Refusals are returned, not thrown: every one of them is something a person
288
+ * needs to read (development install, nothing to update, no plugin manager),
289
+ * and the difference between "refused" and "failed" is worth keeping.
290
+ */
291
+ async function install() {
292
+ if (busy) return { ok: false, applied: false, application: 'failed', version: null, message: '已经有一次更新在跑 · an update is already running', error: null }
293
+ const state = await status({ force: true })
294
+ if (state.error !== null && state.latest === null) {
295
+ return { ok: false, applied: false, application: 'failed', version: null, message: `查不到新版本,装不了 · could not check: ${state.error}`, error: state.error }
296
+ }
297
+ if (!state.behind) {
298
+ return { ok: false, applied: false, application: 'failed', version: state.current, message: `已经是最新版本 ${state.current ?? ''} · already up to date`, error: null }
299
+ }
300
+ if (state.installKind === 'link') {
301
+ return {
302
+ ok: false,
303
+ applied: false,
304
+ application: 'failed',
305
+ version: state.latest,
306
+ message: '当前不是从 npm 安装的(link: 本地安装),自动更新会覆盖你的开发目录,已拒绝 · this is a link: install, so updating would overwrite the working copy',
307
+ error: null,
308
+ }
309
+ }
310
+ const manager = getPluginManager?.()
311
+ if (typeof manager?.installBundle !== 'function') {
312
+ return { ok: false, applied: false, application: 'failed', version: state.latest, message: '宿主没有提供 pluginManager,装不了 · the host exposes no pluginManager', error: null }
313
+ }
314
+ busy = true
315
+ try {
316
+ const spec = `${getName()}@${state.latest}`
317
+ log('info', `自动更新:正在安装 ${spec}`)
318
+ const result = await manager.installBundle(spec, { enabled: true })
319
+ const application = String(result?.application ?? 'failed')
320
+ const applied = application === 'applied' || application === 'restart-required'
321
+ if (applied) {
322
+ // The running code is now a version older than the one on disk; forget the
323
+ // cache so the next status is honest about what is installed.
324
+ cached = null
325
+ }
326
+ const message = applied
327
+ ? application === 'restart-required'
328
+ ? `已安装 ${state.latest},需要重启 Harness 生效 · installed, restart the harness to apply`
329
+ : `已更新到 ${state.latest} · updated to ${state.latest}`
330
+ : `更新没有成功:${result?.error?.diagnostic ?? result?.error?.code ?? application} · update did not apply`
331
+ log(applied ? 'info' : 'warn', `自动更新:${message}`)
332
+ return {
333
+ ok: applied,
334
+ applied,
335
+ application,
336
+ version: applied ? (result?.version ?? state.latest) : state.latest,
337
+ message,
338
+ error: applied ? null : String(result?.error?.diagnostic ?? result?.error?.code ?? ''),
339
+ }
340
+ } catch (error) {
341
+ const message = String(error?.message ?? error)
342
+ log('warn', `自动更新:安装失败 · update install failed: ${message}`)
343
+ return { ok: false, applied: false, application: 'failed', version: state.latest, message: `安装失败:${message}`, error: message }
344
+ } finally {
345
+ busy = false
346
+ }
347
+ }
348
+
349
+ /** Startup hook: check once, and install only when that was asked for. */
350
+ async function onStart() {
351
+ if (settings().enabled === false) return null
352
+ try {
353
+ const state = await status()
354
+ if (state.behind && settings().autoInstall === true && state.installKind === 'registry') {
355
+ return await install()
356
+ }
357
+ return state
358
+ } catch (error) {
359
+ log('warn', `自动更新:启动检查失败 · update: startup check failed: ${error?.message ?? error}`)
360
+ return null
361
+ }
362
+ }
363
+
364
+ return { status, install, onStart, version, peek: () => ({ current, latest: cached?.latest ?? null, checkedAt: cached?.checkedAt ?? null, busy }), lastError: () => lastError }
365
+ }
366
+
367
+ /**
368
+ * The route. One path, two methods: GET answers "am I behind?", POST answers
369
+ * "then update" — and POST is the only verb that can touch the installation, so a
370
+ * page load or a prefetch can never do it by accident.
371
+ */
372
+ export function createUpdateHandler({ getChecker }) {
373
+ return async function handle(request) {
374
+ const checker = getChecker()
375
+ if (!checker) {
376
+ return new Response(JSON.stringify({ ok: false, error: '更新检查还没有就绪 · the update checker is not ready' }), {
377
+ status: 503,
378
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
379
+ })
380
+ }
381
+ try {
382
+ if ((request?.method ?? 'GET').toUpperCase() === 'POST') {
383
+ const result = await checker.install()
384
+ // 200 when it landed, 409 when it was refused (a link: install, nothing to
385
+ // update, no plugin manager). "Refused" and "failed" are both non-2xx on
386
+ // purpose: neither should look like a version change to a caller.
387
+ return new Response(JSON.stringify(result), {
388
+ status: result.ok ? 200 : 409,
389
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
390
+ })
391
+ }
392
+ const url = new URL(request?.url ?? 'http://localhost/')
393
+ const force = url.searchParams.get('force') === '1'
394
+ const state = await checker.status({ force })
395
+ return new Response(JSON.stringify(state), {
396
+ status: 200,
397
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
398
+ })
399
+ } catch (error) {
400
+ return new Response(JSON.stringify({ ok: false, error: `更新检查失败:${error?.message ?? error}` }), {
401
+ status: 500,
402
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
403
+ })
404
+ }
405
+ }
406
+ }