@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/CHANGELOG.md +40 -0
- package/README.en.md +73 -12
- package/README.md +73 -12
- package/client.js +433 -1
- package/docs/update-settings.png +0 -0
- package/index.js +42 -3
- package/package.json +5 -3
- package/src/auto.js +78 -14
- package/src/capture.js +37 -175
- package/src/config.js +37 -0
- package/src/distillapi.js +4 -1
- package/src/distillrun.js +7 -3
- package/src/extract.js +42 -18
- package/src/graph.js +10 -5
- package/src/graphdata.js +10 -1
- package/src/history.js +467 -0
- package/src/pageapi.js +2 -2
- package/src/schema.js +55 -0
- package/src/tools.js +76 -2
- package/src/turns.js +265 -0
- package/src/update.js +406 -0
- package/src/vault.js +21 -11
- package/test/auto.mjs +68 -0
- package/test/client.mjs +15 -5
- package/test/fixtures/session.v4.jsonl.zstd +0 -0
- package/test/history.mjs +249 -0
- package/test/tools.mjs +2 -1
- package/test/update.mjs +401 -0
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
|
+
}
|