ds-bot 0.1.0 → 0.1.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.
@@ -7,10 +7,10 @@ import { join } from 'node:path'
7
7
 
8
8
  const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
9
9
  export const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/'
10
- const MIRROR_REGISTRY = 'https://registry.npmmirror.com/'
10
+ export const MIRROR_REGISTRY = 'https://registry.npmmirror.com/'
11
11
  // Every window that opens asks; within this long the last answer stands.
12
- const CHECK_TTL_MS = 10 * 60_000
13
- const FETCH_TIMEOUT_MS = 8000
12
+ export const CHECK_TTL_MS = 10 * 60_000
13
+ export const FETCH_TIMEOUT_MS = 8000
14
14
  // The first check waits until the host has settled its own start.
15
15
  const STARTUP_DELAY_MS = 15_000
16
16
  const VERSION = /^v?(\d+\.\d+\.\d+(?:-[\w.]+)?)(?:\+[\w.]+)?$/
@@ -60,13 +60,56 @@ export function sourceOf(spec) {
60
60
  }
61
61
 
62
62
  const normalizeRegistry = url => `${String(url).trim().replace(/\/+$/, '')}/`
63
- const encodeName = name => (name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name))
64
- // The project page of the releases, for the "what's new" link.
63
+ export const encodeName = name => (name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name))
64
+
65
+ export const fetchJson = async (url, headers = {}) => {
66
+ const response = await fetch(url, { headers: { accept: 'application/json', ...headers }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
67
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`)
68
+ return response.json()
69
+ }
70
+
71
+ // The registries the plugin manager installs from, in its order: its configured
72
+ // first one (else pnpm's own), then the fallbacks, unless that first one is private.
73
+ export async function registryPlan(config, manager) {
74
+ if (typeof config.updateRegistry === 'string' && config.updateRegistry !== '') return [normalizeRegistry(config.updateRegistry)]
75
+ const configured = await Promise.resolve(manager?.registries?.()).catch(() => null)
76
+ if (!configured) return [OFFICIAL_REGISTRY, MIRROR_REGISTRY]
77
+ const fallbacks = (configured.fallbackRegistries ?? []).map(normalizeRegistry)
78
+ const own = configured.resolved ? normalizeRegistry(configured.resolved) : null
79
+ const first = configured.registry ? normalizeRegistry(configured.registry) : own ?? OFFICIAL_REGISTRY
80
+ const isPublic = first === OFFICIAL_REGISTRY || fallbacks.includes(first)
81
+ return isPublic ? [...new Set([first, ...fallbacks])] : [first]
82
+ }
83
+
84
+ // The plugin manager's `source` for this package; DSH releases before it reported
85
+ // one leave the profile's own dependency entry to say the same.
86
+ export async function installSpecOf(ctx) {
87
+ const manager = ctx.get?.('pluginManager')
88
+ const bundles = await Promise.resolve(manager?.listBundles?.()).catch(() => null)
89
+ const own = Array.isArray(bundles) ? bundles.find(bundle => bundle.name === PKG.name) : undefined
90
+ if (typeof own?.source === 'string') return own.source
91
+ const dir = ctx.get?.('profileContext')?.dir
92
+ if (typeof dir !== 'string') return undefined
93
+ try {
94
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies?.[PKG.name]
95
+ } catch { return undefined }
96
+ }
97
+
98
+ // The public repository the releases live in, for tags and the "what's new" link.
99
+ export const repoSlug = () =>
100
+ /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(PKG.repository?.url ?? PKG.repository ?? ''))?.[1] ?? null
101
+
102
+ const repoUrl = () => {
103
+ const repo = repoSlug()
104
+ return repo ? `https://github.com/${repo}` : null
105
+ }
65
106
  const releasesUrl = () => {
66
- const repo = /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(PKG.repository?.url ?? PKG.repository ?? ''))?.[1]
67
- return repo ? `https://github.com/${repo}/releases` : (PKG.homepage ?? null)
107
+ const repo = repoUrl()
108
+ return repo ? `${repo}/releases` : (PKG.homepage ?? null)
68
109
  }
69
110
 
111
+ export const STARS_TTL_MS = 60 * 60_000
112
+
70
113
  export function install(rt) {
71
114
  const { ctx, config } = rt
72
115
  const enabled = config.updateCheck !== false
@@ -82,31 +125,29 @@ export function install(rt) {
82
125
 
83
126
  const view = () => ({
84
127
  name: status.name, current: status.current, latest: status.latest, source: status.source, notesUrl: status.notesUrl,
128
+ repoUrl: repoUrl(), enabled,
85
129
  checkedAt: status.checkedAt, phase: status.phase, installed: status.installed, error: status.error,
86
130
  available: status.phase !== 'installed' && target !== null && status.latest !== null && newer(status.latest, status.current),
87
131
  })
88
132
 
89
- const fetchJson = async (url, headers = {}) => {
90
- const response = await fetch(url, { headers: { accept: 'application/json', ...headers }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
91
- if (!response.ok) throw new Error(`${url} answered ${response.status}`)
92
- return response.json()
93
- }
94
-
95
- // The registries the plugin manager installs from, in its order: its configured
96
- // first one (else pnpm's own), then the fallbacks, unless that first one is private.
97
- async function registryPlan() {
98
- if (typeof config.updateRegistry === 'string' && config.updateRegistry !== '') return [normalizeRegistry(config.updateRegistry)]
99
- const configured = await Promise.resolve(manager()?.registries?.()).catch(() => null)
100
- if (!configured) return [OFFICIAL_REGISTRY, MIRROR_REGISTRY]
101
- const fallbacks = (configured.fallbackRegistries ?? []).map(normalizeRegistry)
102
- const own = configured.resolved ? normalizeRegistry(configured.resolved) : null
103
- const first = configured.registry ? normalizeRegistry(configured.registry) : own ?? OFFICIAL_REGISTRY
104
- const isPublic = first === OFFICIAL_REGISTRY || fallbacks.includes(first)
105
- return isPublic ? [...new Set([first, ...fallbacks])] : [first]
133
+ // Asked only while the About page is open, so a closed page never calls GitHub.
134
+ // A failed answer keeps the last count and is asked again next time.
135
+ let stars = { count: null, at: 0 }
136
+ let starring = null
137
+ async function repoStars() {
138
+ const repo = repoSlug()
139
+ if (repo === null) return null
140
+ if (Date.now() - stars.at < STARS_TTL_MS) return stars.count
141
+ starring ??= fetchJson(`https://api.github.com/repos/${repo}`, { 'user-agent': `${PKG.name}/${PKG.version}` })
142
+ .then((info) => { if (Number.isInteger(info?.stargazers_count)) stars = { count: info.stargazers_count, at: Date.now() } })
143
+ .catch(error => rt.warn('ds-bot: star count failed: %s', error?.message ?? error))
144
+ .finally(() => { starring = null })
145
+ await starring
146
+ return stars.count
106
147
  }
107
148
 
108
149
  async function newestFromRegistry() {
109
- for (const registry of await registryPlan()) {
150
+ for (const registry of await registryPlan(config, manager())) {
110
151
  try {
111
152
  const version = cleanVersion((await fetchJson(`${registry}${encodeName(PKG.name)}/latest`)).version)
112
153
  if (version) return { version, spec: `${PKG.name}@${version}`, registry }
@@ -124,21 +165,8 @@ export function install(rt) {
124
165
  return { version, spec: `github:${source.owner}/${source.repo}#${fragment}` }
125
166
  }
126
167
 
127
- // The plugin manager's `source` for this package; DSH releases before it reported
128
- // one leave the profile's own dependency entry to say the same.
129
- async function installSpec() {
130
- const bundles = await Promise.resolve(manager()?.listBundles?.()).catch(() => null)
131
- const own = Array.isArray(bundles) ? bundles.find(bundle => bundle.name === PKG.name) : undefined
132
- if (typeof own?.source === 'string') return own.source
133
- const dir = ctx.get?.('profileContext')?.dir
134
- if (typeof dir !== 'string') return undefined
135
- try {
136
- return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies?.[PKG.name]
137
- } catch { return undefined }
138
- }
139
-
140
168
  async function runCheck() {
141
- const source = sourceOf(await installSpec())
169
+ const source = sourceOf(await installSpecOf(ctx))
142
170
  status.source = source.kind
143
171
  let found = null
144
172
  try {
@@ -201,5 +229,5 @@ export function install(rt) {
201
229
  }, 'ds-bot: update check')
202
230
  }
203
231
 
204
- Object.assign(rt, { updateCheck: check, updateStart: start, updateView: view })
232
+ Object.assign(rt, { updateCheck: check, updateStart: start, updateView: view, repoStars })
205
233
  }
package/src/host/usage.js CHANGED
@@ -320,7 +320,18 @@ export function install(rt) {
320
320
  ...Object.values(room.segments ?? {}).flat().map(segment => segment.sessionId),
321
321
  ]))
322
322
 
323
- async function scanSession(ledger, sessionId, asOfSeq, sealable, signal) {
323
+ // DSH reads a subagent Session only through its parent's address; the parent
324
+ // need not exist any more. One with no parent on record cannot be read.
325
+ const addressOf = item => (item.origin !== 'subagent'
326
+ ? { kind: 'session', sessionId: item.sessionId }
327
+ : item.parentSessionId === undefined
328
+ ? undefined
329
+ : { kind: 'subagent', parentSessionId: item.parentSessionId, childSessionId: item.sessionId, mode: 'unknown' })
330
+ // Sessions that already failed to read, so each one warns once, not every scan.
331
+ const unreadable = new Set()
332
+
333
+ async function scanSession(ledger, address, asOfSeq, sealable, signal) {
334
+ const sessionId = address.kind === 'session' ? address.sessionId : address.childSessionId
324
335
  const mark = ledger.mark(sessionId)
325
336
  if (mark?.sealed) return
326
337
  let through = asOfSeq
@@ -338,7 +349,7 @@ export function install(rt) {
338
349
  let beforeSeq
339
350
  for (let reached = false; !reached;) {
340
351
  const page = await ctx.sessionController.page({
341
- address: { kind: 'session', sessionId },
352
+ address,
342
353
  throughSeq: through,
343
354
  ...(beforeSeq === undefined ? {} : { beforeSeq }),
344
355
  maxMessages: PAGE_MESSAGES,
@@ -397,8 +408,19 @@ export function install(rt) {
397
408
  const targets = new Map()
398
409
  for (const item of listed) if (item.blank !== true) targets.set(item.sessionId, item.projections?.asOfSeq)
399
410
  for (const sessionId of owners.keys()) if (!targets.has(sessionId)) targets.set(sessionId, undefined)
400
- await eachLimited([...targets], SCAN_PARALLEL, ([sessionId, asOfSeq]) => scanSession(ledger, sessionId, asOfSeq, ended.has(sessionId), signal)
401
- .catch(error => rt.warn('ds-bot: usage could not read %s: %s', sessionId, error)))
411
+ const addresses = new Map(listed.map(item => [item.sessionId, addressOf(item)]))
412
+ await eachLimited([...targets], SCAN_PARALLEL, async ([sessionId, asOfSeq]) => {
413
+ const address = addresses.has(sessionId) ? addresses.get(sessionId) : { kind: 'session', sessionId }
414
+ if (address === undefined) return
415
+ try {
416
+ await scanSession(ledger, address, asOfSeq, ended.has(sessionId), signal)
417
+ unreadable.delete(sessionId)
418
+ } catch (error) {
419
+ if (unreadable.has(sessionId)) return
420
+ unreadable.add(sessionId)
421
+ rt.warn('ds-bot: usage could not read %s: %s', sessionId, error)
422
+ }
423
+ })
402
424
  })().finally(() => {
403
425
  scannedAt = Date.now()
404
426
  scanning = undefined