@goodandready/dsh-moa 0.2.15 → 0.2.17

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/README.md CHANGED
@@ -282,6 +282,13 @@ npm test
282
282
 
283
283
  ---
284
284
 
285
+
286
+ ## 🛠️ Internal Tooling & Development
287
+
288
+ For local verification and package integrity validation:
289
+ - `npm test`: runs the full test suite (95 tests)
290
+ - `./deploy.sh`: local infrastructure validation script (verifies package size, identity parity in package.json/cordis.patch.yml/client.js, and tests). Excluded from the published npm package.
291
+
285
292
  ## 📄 License
286
293
 
287
294
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Run a non-critical side effect; never throw.
3
+ * Logs at debug/warn when a logger is provided or via console.debug.
4
+ *
5
+ * @param {string} label - Context identifier for telemetry / diagnostics
6
+ * @param {Function} fn - Operation to execute
7
+ * @param {object} [logger] - Optional logger instance
8
+ */
9
+ export function bestEffort(label, fn, logger) {
10
+ const log = (err) => {
11
+ try {
12
+ if (logger && typeof logger.debug === 'function') {
13
+ logger.debug(`[dsh-moa:bestEffort] ${label}:`, err?.message || err)
14
+ } else if (typeof console !== 'undefined' && typeof console.debug === 'function') {
15
+ console.debug(`[dsh-moa:bestEffort] ${label}:`, err?.message || err)
16
+ }
17
+ } catch {
18
+ // Logger itself must never crash the process
19
+ }
20
+ }
21
+
22
+ try {
23
+ const result = fn()
24
+ if (result && typeof result.then === 'function') {
25
+ return result.then(
26
+ (value) => value,
27
+ (err) => {
28
+ log(err)
29
+ return undefined
30
+ }
31
+ )
32
+ }
33
+ return result
34
+ } catch (err) {
35
+ log(err)
36
+ return undefined
37
+ }
38
+ }
package/lib/client.js CHANGED
@@ -113,6 +113,17 @@ window.__ModuleLoader__.load({
113
113
  'badge.disabled': 'MoA Disabled',
114
114
  'config.enabled_label': 'Enable MoA (slash command and turn routing)',
115
115
  'slash.desc': 'Run turn with Mixture of Agents ensemble synthesis (/moa [preset] <prompt>)',
116
+ 'updater.title': 'Plugin Updates',
117
+ 'updater.desc': 'Check npm registry for new versions and update with one click.',
118
+ 'updater.btnCheck': 'Check Updates',
119
+ 'updater.btnUpdate': 'Update to v{version}',
120
+ 'updater.checking': 'Checking registry...',
121
+ 'updater.updating': 'Updating plugin...',
122
+ 'updater.current': 'Current version: v{version}',
123
+ 'updater.available': 'Update available: v{version}',
124
+ 'updater.upToDate': 'Up to date',
125
+ 'updater.checkFailed': 'Registry check failed',
126
+ 'updater.success': 'Successfully updated to v{version}. Restart DSH to apply.',
116
127
  }
117
128
 
118
129
  const zh = {
@@ -205,6 +216,17 @@ window.__ModuleLoader__.load({
205
216
  'badge.disabled': 'MoA 已禁用',
206
217
  'config.enabled_label': '启用 MoA(斜杠命令与轮次拦截路由)',
207
218
  'slash.desc': '使用智能体混合 (MoA) 协同综合运行此轮次 (/moa [预设] <提示词>)',
219
+ 'updater.title': '插件在线更新',
220
+ 'updater.desc': '检查 npm 镜像源上的最新版本并支持一键无感升级。',
221
+ 'updater.btnCheck': '检查更新',
222
+ 'updater.btnUpdate': '升级至 v{version}',
223
+ 'updater.checking': '正在检查版本...',
224
+ 'updater.updating': '正在执行更新...',
225
+ 'updater.current': '当前版本:v{version}',
226
+ 'updater.available': '发现新版本:v{version}',
227
+ 'updater.upToDate': '已是最新版本',
228
+ 'updater.checkFailed': '检查版本失败',
229
+ 'updater.success': '成功更新至 v{version},请重启 DSH 使其生效。',
208
230
  }
209
231
 
210
232
 
@@ -572,6 +594,9 @@ window.__ModuleLoader__.load({
572
594
  handleSave,
573
595
  saveStatus,
574
596
  reload,
597
+ updateState,
598
+ checkUpdates,
599
+ runUpdate,
575
600
  }) {
576
601
  const [selectedPresetName, setSelectedPresetName] = React.useState(defaultPreset || (presets[0] && presets[0].name) || 'default')
577
602
  const [isCreating, setIsCreating] = React.useState(false)
@@ -1381,6 +1406,84 @@ window.__ModuleLoader__.load({
1381
1406
  )
1382
1407
  ),
1383
1408
 
1409
+ /* ── PLUGIN UPDATES ── */
1410
+ React.createElement(
1411
+ 'div',
1412
+ { className: 'moa-section-card' },
1413
+ React.createElement(
1414
+ 'div',
1415
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
1416
+ React.createElement('div', { className: 'moa-sec-title' }, '✨ ' + (t('updater.title') || 'Plugin Updates')),
1417
+ React.createElement(
1418
+ 'div',
1419
+ { style: { display: 'flex', gap: 8 } },
1420
+ React.createElement(
1421
+ 'button',
1422
+ {
1423
+ type: 'button',
1424
+ className: 'moa-btn moa-btn-sub',
1425
+ disabled: updateState?.checking || updateState?.updating,
1426
+ onClick: checkUpdates,
1427
+ },
1428
+ updateState?.checking ? (t('updater.checking') || 'Checking...') : (t('updater.btnCheck') || 'Check Updates')
1429
+ ),
1430
+ updateState?.updateAvailable
1431
+ ? React.createElement(
1432
+ 'button',
1433
+ {
1434
+ type: 'button',
1435
+ className: 'moa-btn moa-btn-sub moa-btn-primary',
1436
+ disabled: updateState?.updating,
1437
+ onClick: runUpdate,
1438
+ },
1439
+ updateState?.updating
1440
+ ? (t('updater.updating') || 'Updating...')
1441
+ : (t('updater.btnUpdate') || 'Update to v{version}').replace('{version}', updateState.latestVersion || '')
1442
+ )
1443
+ : null
1444
+ )
1445
+ ),
1446
+ React.createElement(
1447
+ 'div',
1448
+ { className: 'moa-sec-desc', style: { marginBottom: 8 } },
1449
+ t('updater.desc') || 'Check npm registry for new versions and update with one click.'
1450
+ ),
1451
+ React.createElement(
1452
+ 'div',
1453
+ { style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12 } },
1454
+ React.createElement(
1455
+ 'span',
1456
+ { style: { color: 'var(--dsw-alias-label-secondary)' } },
1457
+ (t('updater.current') || 'Current version: v{version}').replace('{version}', updateState?.currentVersion || '0.2.16')
1458
+ ),
1459
+ updateState?.checking
1460
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))' } }, t('updater.checking'))
1461
+ : updateState?.updateAvailable
1462
+ ? React.createElement(
1463
+ 'span',
1464
+ { style: { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 600 } },
1465
+ (t('updater.available') || 'Update available: v{version}').replace('{version}', updateState?.latestVersion || '')
1466
+ )
1467
+ : updateState?.currentVersion && !updateState?.error
1468
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, '✓ ' + (t('updater.upToDate') || 'Up to date'))
1469
+ : null
1470
+ ),
1471
+ updateState?.notice
1472
+ ? React.createElement(
1473
+ 'div',
1474
+ { className: 'moa-alert-ok', style: { marginTop: 8 } },
1475
+ updateState.notice
1476
+ )
1477
+ : null,
1478
+ updateState?.error
1479
+ ? React.createElement(
1480
+ 'div',
1481
+ { className: 'moa-alert-err', style: { marginTop: 8 } },
1482
+ updateState.error
1483
+ )
1484
+ : null
1485
+ ),
1486
+
1384
1487
  /* ── ENABLED TOGGLE ── */
1385
1488
  React.createElement(
1386
1489
  'label',
@@ -1444,7 +1547,7 @@ window.__ModuleLoader__.load({
1444
1547
  const snapshot = React.useSyncExternalStore
1445
1548
  ? React.useSyncExternalStore(
1446
1549
  React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
1447
- React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'ready' }), [scope]),
1550
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'unavailable' }), [scope]),
1448
1551
  React.useCallback(() => ({ status: 'loading' }), [])
1449
1552
  )
1450
1553
  : null
@@ -1454,6 +1557,65 @@ window.__ModuleLoader__.load({
1454
1557
  const [defaultPreset, setDefaultPreset] = React.useState('default')
1455
1558
  const [availableModels, setAvailableModels] = React.useState([])
1456
1559
  const [saveStatus, setSaveStatus] = React.useState('')
1560
+ const [updateState, setUpdateState] = React.useState({
1561
+ checking: false,
1562
+ updating: false,
1563
+ currentVersion: '0.2.16',
1564
+ latestVersion: undefined,
1565
+ updateAvailable: false,
1566
+ notice: null,
1567
+ error: null,
1568
+ })
1569
+
1570
+ const checkUpdates = async () => {
1571
+ setUpdateState((prev) => ({ ...prev, checking: true, error: null, notice: null }))
1572
+ try {
1573
+ const res = await fetch('/api/dsh-moa/update', { cache: 'no-store' })
1574
+ const data = await res.json()
1575
+ if (data && data.currentVersion) {
1576
+ setUpdateState((prev) => ({
1577
+ ...prev,
1578
+ checking: false,
1579
+ currentVersion: data.currentVersion,
1580
+ latestVersion: data.latestVersion,
1581
+ updateAvailable: Boolean(data.updateAvailable),
1582
+ error: data.latestCheckFailed ? (t('updater.checkFailed') || 'Registry check failed') : null,
1583
+ }))
1584
+ } else {
1585
+ setUpdateState((prev) => ({ ...prev, checking: false, error: data.error || 'Check failed' }))
1586
+ }
1587
+ } catch (err) {
1588
+ setUpdateState((prev) => ({ ...prev, checking: false, error: String(err?.message || err) }))
1589
+ }
1590
+ }
1591
+
1592
+ const runUpdate = async () => {
1593
+ setUpdateState((prev) => ({ ...prev, updating: true, error: null, notice: null }))
1594
+ try {
1595
+ const res = await fetch('/api/dsh-moa/update', {
1596
+ method: 'POST',
1597
+ headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' },
1598
+ })
1599
+ const data = await res.json()
1600
+ if (data && data.updated) {
1601
+ setUpdateState((prev) => ({
1602
+ ...prev,
1603
+ updating: false,
1604
+ updateAvailable: false,
1605
+ currentVersion: data.updatedVersion || data.latestVersion,
1606
+ notice: (t('updater.success') || 'Successfully updated to v{version}. Restart DSH to apply.').replace('{version}', data.updatedVersion || data.latestVersion),
1607
+ }))
1608
+ } else {
1609
+ setUpdateState((prev) => ({
1610
+ ...prev,
1611
+ updating: false,
1612
+ error: data.error || data.message || 'Update failed',
1613
+ }))
1614
+ }
1615
+ } catch (err) {
1616
+ setUpdateState((prev) => ({ ...prev, updating: false, error: String(err?.message || err) }))
1617
+ }
1618
+ }
1457
1619
  const [enabled, setEnabled] = React.useState(true)
1458
1620
  const [hostStatus, setHostStatus] = React.useState('connecting')
1459
1621
  const [stats, setStats] = React.useState({ totalRuns: null, avgCostUsd: null })
@@ -1461,8 +1623,11 @@ window.__ModuleLoader__.load({
1461
1623
  const [leaderboardPresetFilter, setLeaderboardPresetFilter] = React.useState('all')
1462
1624
 
1463
1625
  React.useEffect(() => {
1464
- if (!scope || !snapshot) return
1465
- const snapStatus = snapshot.status || 'ready'
1626
+ if (!scope || !snapshot) {
1627
+ setStatus('unavailable')
1628
+ return
1629
+ }
1630
+ const snapStatus = snapshot.status || 'unavailable'
1466
1631
  if (snapStatus === 'ready') {
1467
1632
  const val = snapshot.value || {}
1468
1633
  if (Array.isArray(val.presets)) setPresets(val.presets)
@@ -1616,6 +1781,9 @@ window.__ModuleLoader__.load({
1616
1781
  handleSave,
1617
1782
  saveStatus,
1618
1783
  reload,
1784
+ updateState,
1785
+ checkUpdates,
1786
+ runUpdate,
1619
1787
  }
1620
1788
  }
1621
1789
 
@@ -1730,7 +1898,7 @@ window.__ModuleLoader__.load({
1730
1898
  return () => {
1731
1899
  for (const off of undo) {
1732
1900
  if (typeof off === 'function') {
1733
- try { off() } catch {}
1901
+ try { off() } catch (offErr) { /* unsubscribe best-effort */ }
1734
1902
  }
1735
1903
  }
1736
1904
  }
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js'
1
2
  import vm from 'node:vm'
2
3
  import fs from 'node:fs/promises'
3
4
  import path from 'node:path'
@@ -94,16 +95,25 @@ function sanitizePath(filePath) {
94
95
  * Formats collected project files into structured Markdown code blocks
95
96
  * suitable for inclusion in candidate system prompts.
96
97
  */
97
- export function formatProjectContext(projectFiles = []) {
98
+ export function formatProjectContext(projectFiles = [], options = {}) {
98
99
  if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
100
+ const { skippedFiles = 0, skippedList = [] } = options
99
101
  const fence = '```'
100
- return projectFiles
102
+ let formatted = projectFiles
101
103
  .map((f) => `### File: ${f.relativePath}\n${fence}\n${f.content}\n${fence}`)
102
104
  .join('\n\n')
105
+
106
+ if (skippedFiles > 0) {
107
+ const preview = skippedList.slice(0, 5).join(', ')
108
+ const more = skippedList.length > 5 ? ` and ${skippedList.length - 5} more` : ''
109
+ const warning = `> ⚠️ **Workspace Scan Notice:** ${skippedFiles} workspace file(s) were skipped or truncated due to size/budget limits or read errors (${preview}${more}). The workspace context below is partial.`
110
+ formatted = `${warning}\n\n${formatted}`
111
+ }
112
+ return formatted
103
113
  }
104
114
 
105
115
  export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
106
- if (!baseDir) return { files: [], totalChars: 0 }
116
+ if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
107
117
 
108
118
  const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
109
119
  const codeExts = new Set([
@@ -114,43 +124,66 @@ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
114
124
  const collected = []
115
125
  let totalChars = 0
116
126
 
127
+ let skippedFiles = 0
128
+ const skippedList = []
129
+
117
130
  async function scan(currentDir, relPrefix = '') {
118
131
  if (totalChars >= maxCharBudget) return
132
+ let list
119
133
  try {
120
- const list = await fs.readdir(currentDir, { withFileTypes: true })
121
- for (const item of list) {
122
- if (totalChars >= maxCharBudget) break
123
- // Skip all dotfiles, including .env and .env.* variants: their
124
- // contents must never be shipped to third-party model providers.
125
- if (item.name.startsWith('.')) continue
134
+ list = await fs.readdir(currentDir, { withFileTypes: true })
135
+ } catch (readDirErr) {
136
+ skippedFiles++
137
+ skippedList.push(relPrefix || '.')
138
+ return
139
+ }
126
140
 
127
- const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
128
- const full = path.join(currentDir, item.name)
141
+ for (const item of list) {
142
+ // Skip all dotfiles, including .env and .env.* variants: their
143
+ // contents must never be shipped to third-party model providers.
144
+ if (item.name.startsWith('.')) continue
129
145
 
130
- if (item.isDirectory()) {
131
- if (!ignoreDirs.has(item.name)) {
132
- await scan(full, rel)
133
- }
134
- } else if (item.isFile()) {
135
- const ext = path.extname(item.name).toLowerCase()
136
- if (codeExts.has(ext)) {
137
- try {
138
- const stat = await fs.stat(full)
139
- if (stat.size > 100000) continue // skip files > 100kb
140
- const content = await fs.readFile(full, 'utf8')
141
- const budgetRemaining = maxCharBudget - totalChars
142
- const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
143
- totalChars += truncated.length
144
- collected.push({ relativePath: rel, content: truncated, size: stat.size })
145
- } catch {}
146
+ const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
147
+ const full = path.join(currentDir, item.name)
148
+
149
+ if (totalChars >= maxCharBudget) {
150
+ if (item.isFile() && codeExts.has(path.extname(item.name).toLowerCase())) {
151
+ skippedFiles++
152
+ skippedList.push(`${rel} (budget limit)`)
153
+ }
154
+ continue
155
+ }
156
+
157
+ if (item.isDirectory()) {
158
+ if (!ignoreDirs.has(item.name)) {
159
+ await scan(full, rel)
160
+ }
161
+ } else if (item.isFile()) {
162
+ const ext = path.extname(item.name).toLowerCase()
163
+ if (codeExts.has(ext)) {
164
+ try {
165
+ const stat = await fs.stat(full)
166
+ if (stat.size > 100000) {
167
+ skippedFiles++
168
+ skippedList.push(`${rel} (>100KB)`)
169
+ continue
170
+ }
171
+ const content = await fs.readFile(full, 'utf8')
172
+ const budgetRemaining = maxCharBudget - totalChars
173
+ const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
174
+ totalChars += truncated.length
175
+ collected.push({ relativePath: rel, content: truncated, size: stat.size })
176
+ } catch (fileErr) {
177
+ skippedFiles++
178
+ skippedList.push(rel)
146
179
  }
147
180
  }
148
181
  }
149
- } catch {}
182
+ }
150
183
  }
151
184
 
152
185
  await scan(baseDir)
153
- return { files: collected, totalChars }
186
+ return { files: collected, totalChars, skippedFiles, skippedList }
154
187
  }
155
188
 
156
189
  /**
@@ -224,7 +257,7 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
224
257
  const entries = await getFilesRecursively(winningDir)
225
258
  if (entries.length === 0) {
226
259
  if (!options.keepMoa) {
227
- try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
260
+ await bestEffort('clean moaRoot on empty candidate', () => fs.rm(moaRoot, { recursive: true, force: true }))
228
261
  }
229
262
  return []
230
263
  }
@@ -247,7 +280,7 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
247
280
  return promoted
248
281
  } catch (err) {
249
282
  if (!options.keepMoa) {
250
- try { await fs.rm(moaRoot, { recursive: true, force: true }) } catch {}
283
+ await bestEffort('clean moaRoot on promote error', () => fs.rm(moaRoot, { recursive: true, force: true }))
251
284
  }
252
285
  return []
253
286
  }
@@ -259,9 +292,7 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
259
292
  export async function cleanMoaWorkspaces(baseDir) {
260
293
  if (!baseDir) return
261
294
  const moaRoot = path.join(baseDir, '.moa')
262
- try {
263
- await fs.rm(moaRoot, { recursive: true, force: true })
264
- } catch {}
295
+ await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
265
296
  }
266
297
 
267
298
  async function getFilesRecursively(dir) {
@@ -269,13 +300,14 @@ async function getFilesRecursively(dir) {
269
300
  try {
270
301
  const stat = await fs.stat(dir)
271
302
  if (!stat.isDirectory()) return []
272
- } catch {
303
+ } catch (statErr) {
273
304
  return []
274
305
  }
275
306
 
276
307
  async function scan(current, relPrefix = '') {
308
+ let list
277
309
  try {
278
- const list = await fs.readdir(current, { withFileTypes: true })
310
+ list = await fs.readdir(current, { withFileTypes: true })
279
311
  for (const item of list) {
280
312
  const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
281
313
  const full = path.join(current, item.name)
@@ -285,7 +317,9 @@ async function getFilesRecursively(dir) {
285
317
  results.push(rel)
286
318
  }
287
319
  }
288
- } catch {}
320
+ } catch (readErr) {
321
+ return
322
+ }
289
323
  }
290
324
  await scan(dir)
291
325
  return results
package/lib/history.js CHANGED
@@ -15,7 +15,7 @@ const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
15
15
  /** Max history file size before rotation (10 MB) */
16
16
  export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
17
17
 
18
- export function getHistoryFilePath(customDir) {
18
+ function getHistoryFilePath(customDir) {
19
19
  if (customDir) {
20
20
  return path.join(customDir, '.moa-history.jsonl')
21
21
  }
@@ -33,7 +33,7 @@ export function rotateHistoryFileIfNeeded(filePath = DEFAULT_HISTORY_FILE, maxBy
33
33
  if (stat.size >= maxBytes) {
34
34
  const backupPath = `${filePath}.1`
35
35
  if (fs.existsSync(backupPath)) {
36
- try { fs.unlinkSync(backupPath) } catch {}
36
+ try { fs.unlinkSync(backupPath) } catch (unlinkErr) { /* backup unlink best-effort */ }
37
37
  }
38
38
  fs.renameSync(filePath, backupPath)
39
39
  return true
@@ -241,7 +241,7 @@ export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
241
241
  try {
242
242
  const item = JSON.parse(lines[i])
243
243
  if (item && item.id === runId) return item
244
- } catch {}
244
+ } catch (parseErr) { /* corrupt line skipped */ }
245
245
  }
246
246
  return null
247
247
  } catch {
@@ -270,7 +270,7 @@ export function exportMoaHistory(filePath = DEFAULT_HISTORY_FILE, options = {})
270
270
  if (!item) continue
271
271
  if (presetFilter && presetFilter !== 'all' && item.preset !== presetFilter) continue
272
272
  records.push(item)
273
- } catch {}
273
+ } catch (parseErr) { /* corrupt line skipped */ }
274
274
  }
275
275
 
276
276
  if (format === 'csv') {