@swarmclawai/swarmclaw 1.9.34 → 1.9.37

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.
@@ -68,3 +68,125 @@ test('appendMessage notifies both generic and per-session message topics', () =>
68
68
  assert.deepEqual(output.genericTopics, ['messages'])
69
69
  assert.deepEqual(output.sessionTopics, ['messages:sess-notify'])
70
70
  })
71
+
72
+ test('lazy migration compacts legacy session message blobs after table persistence', () => {
73
+ const output = runWithTempDataDir<{
74
+ returnedTexts: string[]
75
+ secondReadTexts: string[]
76
+ blobMessageCount: number
77
+ messageCount: number
78
+ lastMessageText: string | null
79
+ }>(`
80
+ const storageMod = await import('@/lib/server/storage')
81
+ const repoMod = await import('@/lib/server/messages/message-repository')
82
+ const storage = storageMod.default || storageMod
83
+ const repo = repoMod.default || repoMod
84
+
85
+ storage.saveSessions({
86
+ 'sess-legacy-blob': {
87
+ id: 'sess-legacy-blob',
88
+ name: 'Legacy blob session',
89
+ cwd: process.env.WORKSPACE_DIR,
90
+ user: 'tester',
91
+ provider: 'openai',
92
+ model: 'gpt-5',
93
+ claudeSessionId: null,
94
+ codexThreadId: null,
95
+ opencodeSessionId: null,
96
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
97
+ messages: [
98
+ { role: 'user', text: 'first legacy prompt', time: 1 },
99
+ { role: 'assistant', text: 'first legacy reply', time: 2 },
100
+ { role: 'user', text: 'second legacy prompt', time: 3 },
101
+ ],
102
+ createdAt: Date.now(),
103
+ lastActiveAt: Date.now(),
104
+ },
105
+ })
106
+
107
+ const returned = repo.getMessages('sess-legacy-blob')
108
+ const secondRead = repo.getMessages('sess-legacy-blob')
109
+ const stored = storage.loadSessions()['sess-legacy-blob']
110
+
111
+ console.log(JSON.stringify({
112
+ returnedTexts: returned.map((message) => message.text),
113
+ secondReadTexts: secondRead.map((message) => message.text),
114
+ blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
115
+ messageCount: stored.messageCount,
116
+ lastMessageText: stored.lastMessageSummary?.text || null,
117
+ }))
118
+ `, { prefix: 'swarmclaw-message-repo-compact-' })
119
+
120
+ assert.deepEqual(output.returnedTexts, [
121
+ 'first legacy prompt',
122
+ 'first legacy reply',
123
+ 'second legacy prompt',
124
+ ])
125
+ assert.deepEqual(output.secondReadTexts, output.returnedTexts)
126
+ assert.equal(output.blobMessageCount, 0)
127
+ assert.equal(output.messageCount, 3)
128
+ assert.equal(output.lastMessageText, 'second legacy prompt')
129
+ })
130
+
131
+ test('bulk migration reports compaction for table-backed legacy blobs', () => {
132
+ const output = runWithTempDataDir<{
133
+ result: {
134
+ migrated: number
135
+ compacted: number
136
+ skipped: number
137
+ total: number
138
+ }
139
+ blobMessageCount: number
140
+ messageCount: number
141
+ }>(`
142
+ const storageMod = await import('@/lib/server/storage')
143
+ const repoMod = await import('@/lib/server/messages/message-repository')
144
+ const storage = storageMod.default || storageMod
145
+ const repo = repoMod.default || repoMod
146
+
147
+ const messages = [
148
+ { role: 'user', text: 'stale blob prompt', time: 10 },
149
+ { role: 'assistant', text: 'stale blob reply', time: 20 },
150
+ ]
151
+
152
+ storage.saveSessions({
153
+ 'sess-table-backed-blob': {
154
+ id: 'sess-table-backed-blob',
155
+ name: 'Table backed blob session',
156
+ cwd: process.env.WORKSPACE_DIR,
157
+ user: 'tester',
158
+ provider: 'openai',
159
+ model: 'gpt-5',
160
+ claudeSessionId: null,
161
+ codexThreadId: null,
162
+ opencodeSessionId: null,
163
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
164
+ messages,
165
+ createdAt: Date.now(),
166
+ lastActiveAt: Date.now(),
167
+ },
168
+ })
169
+
170
+ const db = storage.getDb()
171
+ const insert = db.prepare('INSERT INTO session_messages (session_id, seq, data) VALUES (?, ?, ?)')
172
+ messages.forEach((message, index) => {
173
+ insert.run('sess-table-backed-blob', index, JSON.stringify(message))
174
+ })
175
+
176
+ const result = repo.migrateAllSessions()
177
+ const stored = storage.loadSessions()['sess-table-backed-blob']
178
+
179
+ console.log(JSON.stringify({
180
+ result,
181
+ blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
182
+ messageCount: stored.messageCount,
183
+ }))
184
+ `, { prefix: 'swarmclaw-message-repo-migrate-report-' })
185
+
186
+ assert.equal(output.result.migrated, 0)
187
+ assert.equal(output.result.compacted, 1)
188
+ assert.equal(output.result.skipped, 1)
189
+ assert.equal(output.result.total, 1)
190
+ assert.equal(output.blobMessageCount, 0)
191
+ assert.equal(output.messageCount, 2)
192
+ })
@@ -86,6 +86,38 @@ function summarizeForMeta(message: Message): Message {
86
86
  }
87
87
  }
88
88
 
89
+ function getLastAssistantAt(messages: Message[]): number | null {
90
+ for (let i = messages.length - 1; i >= 0; i--) {
91
+ if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
92
+ return messages[i].time
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ function compactDeprecatedBlobMessages(
99
+ sessionId: string,
100
+ persistedCount: number,
101
+ lastMsg: Message | null,
102
+ lastAssistantAt: number | null,
103
+ ): boolean {
104
+ let compacted = false
105
+ patchSession(sessionId, (current) => {
106
+ if (!current) return null
107
+ const blobCount = Array.isArray(current.messages) ? current.messages.length : 0
108
+ if (blobCount === 0) return current
109
+ if (persistedCount < blobCount) return current
110
+
111
+ current.messages = []
112
+ current.messageCount = persistedCount
113
+ current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
114
+ if (lastAssistantAt !== null) current.lastAssistantAt = lastAssistantAt
115
+ compacted = true
116
+ return current
117
+ })
118
+ return compacted
119
+ }
120
+
89
121
  // ---------------------------------------------------------------------------
90
122
  // Session metadata sync — keeps messageCount / lastMessageSummary on the blob
91
123
  // ---------------------------------------------------------------------------
@@ -139,18 +171,14 @@ function lazyMigrateSession(sessionId: string): Message[] | null {
139
171
  ins.run(sessionId, i, JSON.stringify(messages[i]))
140
172
  }
141
173
 
142
- // Compute metadata on the blob (keep messages intact for backward compat)
174
+ // Compute metadata before compacting deprecated blob storage.
143
175
  const lastMsg = messages[messages.length - 1]
144
- let lastAssistantAt: number | null = null
145
- for (let i = messages.length - 1; i >= 0; i--) {
146
- if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
147
- lastAssistantAt = messages[i].time
148
- break
149
- }
150
- }
176
+ const lastAssistantAt = getLastAssistantAt(messages)
177
+ const persistedCount = rowCount(sessionId)
151
178
 
152
179
  patchSession(sessionId, (current) => {
153
180
  if (!current) return null
181
+ current.messages = persistedCount >= messages.length ? [] : current.messages
154
182
  current.messageCount = messages.length
155
183
  current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
156
184
  if (lastAssistantAt !== null && typeof current.lastAssistantAt !== 'number') {
@@ -183,6 +211,7 @@ export function getMessages(sessionId: string): Message[] {
183
211
  const m = parseMsg(row.data)
184
212
  if (m) out.push(m)
185
213
  }
214
+ compactDeprecatedBlobMessages(sessionId, out.length, out[out.length - 1] || null, getLastAssistantAt(out))
186
215
  return out
187
216
  }, { sessionId })
188
217
  }
@@ -338,10 +367,16 @@ export function deleteSessionMessages(sessionId: string): void {
338
367
  // Bulk migration (for CLI / admin endpoint)
339
368
  // ---------------------------------------------------------------------------
340
369
 
341
- export function migrateAllSessions(): { migrated: number; skipped: number; total: number } {
370
+ export function migrateAllSessions(): {
371
+ migrated: number
372
+ compacted: number
373
+ skipped: number
374
+ total: number
375
+ } {
342
376
  const db = getDb()
343
377
  const rows = db.prepare('SELECT id, data FROM sessions').all() as Array<{ id: string; data: string }>
344
378
  let migrated = 0
379
+ let compacted = 0
345
380
  let skipped = 0
346
381
 
347
382
  for (const row of rows) {
@@ -351,16 +386,37 @@ export function migrateAllSessions(): { migrated: number; skipped: number; total
351
386
  skipped++
352
387
  continue
353
388
  }
354
- if (rowCount(row.id) > 0) {
389
+
390
+ const persistedCount = rowCount(row.id)
391
+ if (persistedCount > 0) {
392
+ const persistedRows = stmts().selectAll.all(row.id) as Array<{ data: string }>
393
+ const persistedMessages: Message[] = []
394
+ for (const persistedRow of persistedRows) {
395
+ const message = parseMsg(persistedRow.data)
396
+ if (message) persistedMessages.push(message)
397
+ }
398
+ if (compactDeprecatedBlobMessages(
399
+ row.id,
400
+ persistedCount,
401
+ persistedMessages[persistedMessages.length - 1] || null,
402
+ getLastAssistantAt(persistedMessages),
403
+ )) {
404
+ compacted++
405
+ }
355
406
  skipped++
356
407
  continue
357
408
  }
409
+
358
410
  lazyMigrateSession(row.id)
359
411
  migrated++
412
+ const stored = loadSession(row.id)
413
+ if (!Array.isArray(stored?.messages) || stored.messages.length === 0) {
414
+ compacted++
415
+ }
360
416
  } catch {
361
417
  skipped++
362
418
  }
363
419
  }
364
420
 
365
- return { migrated, skipped, total: rows.length }
421
+ return { migrated, compacted, skipped, total: rows.length }
366
422
  }
@@ -23,10 +23,10 @@ const NEXT_CONFIG_FILES = [
23
23
  ]
24
24
 
25
25
  function readPackageJson(dir: string): PackageJsonLike | null {
26
- const pkgPath = path.join(dir, 'package.json')
27
- if (!fs.existsSync(pkgPath)) return null
26
+ const pkgPath = path.join(/*turbopackIgnore: true*/ dir, 'package.json')
27
+ if (!fs.existsSync(/*turbopackIgnore: true*/ pkgPath)) return null
28
28
  try {
29
- const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
29
+ const parsed: unknown = JSON.parse(fs.readFileSync(/*turbopackIgnore: true*/ pkgPath, 'utf8'))
30
30
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
31
31
  ? parsed as PackageJsonLike
32
32
  : null
@@ -46,7 +46,10 @@ function hasNextScript(pkg: PackageJsonLike): boolean {
46
46
  }
47
47
 
48
48
  function hasNextConfig(dir: string): boolean {
49
- return NEXT_CONFIG_FILES.some((file) => fs.existsSync(path.join(dir, file)))
49
+ return NEXT_CONFIG_FILES.some((file) => {
50
+ const configPath = path.join(/*turbopackIgnore: true*/ dir, file)
51
+ return fs.existsSync(/*turbopackIgnore: true*/ configPath)
52
+ })
50
53
  }
51
54
 
52
55
  function classifyPackageRoot(dir: string, pkg: PackageJsonLike): FrameworkKind {
@@ -0,0 +1,5 @@
1
+ export type ThemeMode = 'light' | 'dark' | 'system'
2
+
3
+ export function normalizeThemeMode(value: unknown): ThemeMode {
4
+ return value === 'light' || value === 'system' ? value : 'dark'
5
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SessionResetMode } from './session'
2
2
  import type { ExtensionManagedLocalFolderDeclaration } from './extension'
3
+ import type { ThemeMode } from '@/lib/theme-mode'
3
4
 
4
5
  // --- App Settings ---
5
6
  export type LoopMode = 'bounded' | 'ongoing'
@@ -134,6 +135,7 @@ export interface AppSettings {
134
135
  defaultAgentId?: string | null
135
136
  // Theme
136
137
  themeHue?: string
138
+ themeMode?: ThemeMode
137
139
  // Web search provider
138
140
  webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave' | 'exa'
139
141
  searxngUrl?: string
@@ -1,7 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  import { useState } from 'react'
4
+ import { Monitor, Moon, Sun } from 'lucide-react'
5
+ import { useTheme } from 'next-themes'
4
6
  import { toast } from 'sonner'
7
+ import { normalizeThemeMode, type ThemeMode } from '@/lib/theme-mode'
5
8
  import type { SettingsSectionProps } from './types'
6
9
 
7
10
  const PRESETS = [
@@ -13,12 +16,26 @@ const PRESETS = [
13
16
  { label: 'Rose', color: '#2e1a24' },
14
17
  ]
15
18
 
19
+ const THEME_MODES: Array<{ id: ThemeMode; label: string; Icon: typeof Sun }> = [
20
+ { id: 'light', label: 'Light', Icon: Sun },
21
+ { id: 'dark', label: 'Dark', Icon: Moon },
22
+ { id: 'system', label: 'System', Icon: Monitor },
23
+ ]
24
+
16
25
  export function ThemeSection({ appSettings, patchSettings, inputClass }: SettingsSectionProps) {
26
+ const { setTheme } = useTheme()
17
27
  const currentHue = appSettings.themeHue || PRESETS[0].color
28
+ const currentMode = normalizeThemeMode(appSettings.themeMode)
18
29
  const [customHex, setCustomHex] = useState(
19
30
  PRESETS.some((p) => p.color === currentHue) ? '' : currentHue,
20
31
  )
21
32
 
33
+ const applyMode = (mode: ThemeMode) => {
34
+ setTheme(mode)
35
+ patchSettings({ themeMode: mode })
36
+ toast.success('Theme updated')
37
+ }
38
+
22
39
  const applyHue = (color: string) => {
23
40
  patchSettings({ themeHue: color })
24
41
  document.documentElement.style.setProperty('--neutral-tint', color)
@@ -38,9 +55,32 @@ export function ThemeSection({ appSettings, patchSettings, inputClass }: Setting
38
55
  Theme
39
56
  </h3>
40
57
  <p className="text-[12px] text-text-3 mb-5">
41
- Shift the UI color palette. Pick a preset or enter a custom hex color.
58
+ Choose a color scheme and shift the UI palette with a preset or custom hex color.
42
59
  </p>
43
60
 
61
+ <div className="inline-grid grid-cols-3 rounded-[8px] border border-white/[0.08] bg-white/[0.03] p-1 mb-5">
62
+ {THEME_MODES.map(({ id, label, Icon }) => {
63
+ const isActive = currentMode === id
64
+ return (
65
+ <button
66
+ key={id}
67
+ type="button"
68
+ onClick={() => applyMode(id)}
69
+ aria-pressed={isActive}
70
+ className={`h-9 px-3 rounded-[6px] flex items-center justify-center gap-2 text-[12px] font-600 transition-colors ${
71
+ isActive
72
+ ? 'bg-accent text-white'
73
+ : 'text-text-3 hover:text-text hover:bg-white/[0.05]'
74
+ }`}
75
+ title={label}
76
+ >
77
+ <Icon className="w-4 h-4" aria-hidden="true" />
78
+ <span>{label}</span>
79
+ </button>
80
+ )
81
+ })}
82
+ </div>
83
+
44
84
  {/* Preset swatches */}
45
85
  <div className="flex flex-wrap gap-3 mb-4">
46
86
  {PRESETS.map((preset) => {