@jsbot-tellus/core 2.3.0 → 2.5.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.
Files changed (120) hide show
  1. package/README.md +19 -0
  2. package/bot.config.example.json +3 -0
  3. package/dist/app.js +215 -98
  4. package/dist/app.js.map +1 -1
  5. package/dist/core/client.d.ts +1 -0
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +3 -0
  8. package/dist/core/client.js.map +1 -1
  9. package/dist/core/config/schema.d.ts +19 -0
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +37 -0
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/dispatch/command.d.ts.map +1 -1
  14. package/dist/core/dispatch/command.js +5 -1
  15. package/dist/core/dispatch/command.js.map +1 -1
  16. package/dist/core/dispatch/router.d.ts +3 -0
  17. package/dist/core/dispatch/router.d.ts.map +1 -1
  18. package/dist/core/dispatch/router.js +14 -1
  19. package/dist/core/dispatch/router.js.map +1 -1
  20. package/dist/core/index.d.ts +2 -2
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/kernel.d.ts +8 -0
  24. package/dist/core/kernel.d.ts.map +1 -1
  25. package/dist/core/kernel.js +17 -0
  26. package/dist/core/kernel.js.map +1 -1
  27. package/dist/core/logging/log-buffer.d.ts +27 -0
  28. package/dist/core/logging/log-buffer.d.ts.map +1 -0
  29. package/dist/core/logging/log-buffer.js +71 -0
  30. package/dist/core/logging/log-buffer.js.map +1 -0
  31. package/dist/core/logging/logger.d.ts +3 -0
  32. package/dist/core/logging/logger.d.ts.map +1 -1
  33. package/dist/core/logging/logger.js +6 -3
  34. package/dist/core/logging/logger.js.map +1 -1
  35. package/dist/core/plugin/command-params.d.ts +4 -3
  36. package/dist/core/plugin/command-params.d.ts.map +1 -1
  37. package/dist/core/plugin/command-params.js +122 -3
  38. package/dist/core/plugin/command-params.js.map +1 -1
  39. package/dist/core/storage/session.d.ts +15 -0
  40. package/dist/core/storage/session.d.ts.map +1 -1
  41. package/dist/core/storage/session.js +55 -0
  42. package/dist/core/storage/session.js.map +1 -1
  43. package/dist/core/web/api.d.ts +16 -0
  44. package/dist/core/web/api.d.ts.map +1 -0
  45. package/dist/core/web/api.js +617 -0
  46. package/dist/core/web/api.js.map +1 -0
  47. package/dist/core/web/auth.d.ts +43 -0
  48. package/dist/core/web/auth.d.ts.map +1 -0
  49. package/dist/core/web/auth.js +161 -0
  50. package/dist/core/web/auth.js.map +1 -0
  51. package/dist/core/web/config-store.d.ts +30 -0
  52. package/dist/core/web/config-store.d.ts.map +1 -0
  53. package/dist/core/web/config-store.js +216 -0
  54. package/dist/core/web/config-store.js.map +1 -0
  55. package/dist/core/web/event-feed.d.ts +26 -0
  56. package/dist/core/web/event-feed.d.ts.map +1 -0
  57. package/dist/core/web/event-feed.js +107 -0
  58. package/dist/core/web/event-feed.js.map +1 -0
  59. package/dist/core/web/http.d.ts +39 -0
  60. package/dist/core/web/http.d.ts.map +1 -0
  61. package/dist/core/web/http.js +233 -0
  62. package/dist/core/web/http.js.map +1 -0
  63. package/dist/core/web/index.d.ts +11 -0
  64. package/dist/core/web/index.d.ts.map +1 -0
  65. package/dist/core/web/index.js +6 -0
  66. package/dist/core/web/index.js.map +1 -0
  67. package/dist/core/web/server.d.ts +30 -0
  68. package/dist/core/web/server.d.ts.map +1 -0
  69. package/dist/core/web/server.js +437 -0
  70. package/dist/core/web/server.js.map +1 -0
  71. package/dist/core/web/types.d.ts +84 -0
  72. package/dist/core/web/types.d.ts.map +1 -0
  73. package/dist/core/web/types.js +2 -0
  74. package/dist/core/web/types.js.map +1 -0
  75. package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
  76. package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
  77. package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
  78. package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
  79. package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
  80. package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
  81. package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
  82. package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
  83. package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
  84. package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
  85. package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
  86. package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
  87. package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
  88. package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
  89. package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
  90. package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
  91. package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
  92. package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
  93. package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
  94. package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
  95. package/dist/webui/assets/api-CNzxhTWe.js +3 -0
  96. package/dist/webui/assets/index-CUvpnClx.js +50 -0
  97. package/dist/webui/assets/index-CePlRZX6.css +1 -0
  98. package/dist/webui/index.html +110 -0
  99. package/docs/plugin-api.md +2 -0
  100. package/docs/webui.md +142 -0
  101. package/package.json +14 -3
  102. package/src/app.ts +286 -118
  103. package/src/core/client.ts +4 -0
  104. package/src/core/config/schema.ts +40 -0
  105. package/src/core/dispatch/command.ts +7 -1
  106. package/src/core/dispatch/router.ts +17 -1
  107. package/src/core/index.ts +3 -0
  108. package/src/core/kernel.ts +25 -1
  109. package/src/core/logging/log-buffer.ts +109 -0
  110. package/src/core/logging/logger.ts +7 -3
  111. package/src/core/plugin/command-params.ts +165 -7
  112. package/src/core/storage/session.ts +95 -0
  113. package/src/core/web/api.ts +932 -0
  114. package/src/core/web/auth.ts +245 -0
  115. package/src/core/web/config-store.ts +333 -0
  116. package/src/core/web/event-feed.ts +163 -0
  117. package/src/core/web/http.ts +340 -0
  118. package/src/core/web/index.ts +25 -0
  119. package/src/core/web/server.ts +621 -0
  120. package/src/core/web/types.ts +100 -0
@@ -1,6 +1,7 @@
1
+ import type { SnowLumaEvent } from '@snowluma/sdk'
1
2
  import type { BotClient } from './client.js'
2
3
  import type { AppConfig } from './config/schema.js'
3
- import { createBotApi } from './api.js'
4
+ import { createBotApi, type BotApi } from './api.js'
4
5
  import { Router } from './dispatch/router.js'
5
6
  import { PerKeyTaskQueue } from './dispatch/task-queue.js'
6
7
  import { Lifecycle, type LifecycleState } from './lifecycle/lifecycle.js'
@@ -28,10 +29,13 @@ export interface KernelOptions {
28
29
  plugins?: AnyPluginDefinition[] | undefined
29
30
  installer?: PluginInstaller | undefined
30
31
  onRestart?: (() => void) | undefined
32
+ onEvent?: ((event: SnowLumaEvent) => void) | undefined
31
33
  drainTimeoutMs?: number | undefined
32
34
  }
33
35
 
34
36
  export class Kernel {
37
+ readonly api: BotApi
38
+
35
39
  private readonly config: AppConfig
36
40
  private readonly client: BotClient
37
41
  private readonly logger: Logger
@@ -62,12 +66,16 @@ export class Kernel {
62
66
  const tasks = new PerKeyTaskQueue(options.logger)
63
67
  const api = createBotApi(options.client, sender)
64
68
 
69
+ this.api = api
65
70
  this.tasks = tasks
66
71
  this.router = new Router({
67
72
  sender,
68
73
  roles,
69
74
  gate,
70
75
  logger: options.logger,
76
+ ...(options.onEvent !== undefined
77
+ ? { onEvent: options.onEvent }
78
+ : {}),
71
79
  })
72
80
 
73
81
  let host: PluginHost
@@ -131,6 +139,22 @@ export class Kernel {
131
139
  return this.lifecycle.state
132
140
  }
133
141
 
142
+ get uptimeMs(): number {
143
+ return this.startedAt === null ? 0 : Date.now() - this.startedAt
144
+ }
145
+
146
+ get isConnected(): boolean {
147
+ return this.client.isConnected
148
+ }
149
+
150
+ listPlugins() {
151
+ return this.host.listStatus()
152
+ }
153
+
154
+ listCommands() {
155
+ return this.router.listCommands()
156
+ }
157
+
134
158
  start(): Promise<void> {
135
159
  return this.lifecycle.start()
136
160
  }
@@ -0,0 +1,109 @@
1
+ import {
2
+ formatValue,
3
+ logLevelWeight,
4
+ type LogLevel,
5
+ type LogRecord,
6
+ } from './logger.js'
7
+
8
+ export interface LogEntry {
9
+ time: number
10
+ level: LogLevel
11
+ scope: string
12
+ message: string
13
+ args: string[]
14
+ }
15
+
16
+ export interface LogFilter {
17
+ level?: LogLevel | undefined
18
+ scope?: string | undefined
19
+ query?: string | undefined
20
+ limit?: number | undefined
21
+ }
22
+
23
+ export function matchesLogFilter(entry: LogEntry, filter: LogFilter): boolean {
24
+ if (
25
+ filter.level !== undefined &&
26
+ logLevelWeight(entry.level) < logLevelWeight(filter.level)
27
+ ) {
28
+ return false
29
+ }
30
+
31
+ if (
32
+ filter.scope !== undefined &&
33
+ filter.scope.length > 0 &&
34
+ entry.scope !== filter.scope
35
+ ) {
36
+ return false
37
+ }
38
+
39
+ if (filter.query !== undefined && filter.query.length > 0) {
40
+ const query = filter.query.toLowerCase()
41
+ const haystack = [entry.scope, entry.message, ...entry.args]
42
+ .join(' ')
43
+ .toLowerCase()
44
+
45
+ if (!haystack.includes(query)) return false
46
+ }
47
+
48
+ return true
49
+ }
50
+
51
+ export class LogBuffer {
52
+ private readonly entries: LogEntry[] = []
53
+ private readonly listeners = new Set<(entry: LogEntry) => void>()
54
+
55
+ constructor(private readonly capacity = 2_000) {}
56
+
57
+ get size(): number {
58
+ return this.entries.length
59
+ }
60
+
61
+ push(record: LogRecord): void {
62
+ const entry: LogEntry = {
63
+ time: record.time.getTime(),
64
+ level: record.level,
65
+ scope: record.scope,
66
+ message: record.message,
67
+ args: record.args.map(formatValue),
68
+ }
69
+
70
+ this.entries.push(entry)
71
+
72
+ if (this.entries.length > this.capacity) {
73
+ this.entries.splice(0, this.entries.length - this.capacity)
74
+ }
75
+
76
+ for (const listener of this.listeners) {
77
+ try {
78
+ listener(entry)
79
+ } catch {
80
+ // Log consumers must never break logging.
81
+ }
82
+ }
83
+ }
84
+
85
+ list(filter: LogFilter = {}): LogEntry[] {
86
+ const matched = this.entries.filter((entry) =>
87
+ matchesLogFilter(entry, filter)
88
+ )
89
+ const limit = filter.limit
90
+
91
+ if (limit === undefined || limit < 0 || limit >= matched.length) {
92
+ return matched
93
+ }
94
+
95
+ return matched.slice(-limit)
96
+ }
97
+
98
+ clear(): void {
99
+ this.entries.length = 0
100
+ }
101
+
102
+ subscribe(listener: (entry: LogEntry) => void): () => void {
103
+ this.listeners.add(listener)
104
+
105
+ return () => {
106
+ this.listeners.delete(listener)
107
+ }
108
+ }
109
+ }
@@ -34,7 +34,7 @@ export interface Logger {
34
34
  child(scope: string): Logger
35
35
  }
36
36
 
37
- function formatValue(value: unknown): string {
37
+ export function formatValue(value: unknown): string {
38
38
  if (value instanceof Error) {
39
39
  return value.stack ?? `${value.name}: ${value.message}`
40
40
  }
@@ -49,7 +49,7 @@ function formatValue(value: unknown): string {
49
49
  }
50
50
  }
51
51
 
52
- const defaultSink: LogSink = (record) => {
52
+ export const defaultLogSink: LogSink = (record) => {
53
53
  const time = record.time.toISOString()
54
54
  const scope = record.scope ? ` [${record.scope}]` : ''
55
55
  const args =
@@ -92,10 +92,14 @@ export function parseLogLevel(
92
92
  return normalized
93
93
  }
94
94
 
95
+ export function logLevelWeight(level: LogLevel): number {
96
+ return LEVEL_WEIGHT[level]
97
+ }
98
+
95
99
  export function createLogger(options: LoggerOptions = {}): Logger {
96
100
  const threshold = options.level ?? 'info'
97
101
  const scope = options.scope ?? 'bot'
98
- const sink = options.sink ?? defaultSink
102
+ const sink = options.sink ?? defaultLogSink
99
103
 
100
104
  const emit = (level: LogLevel, message: string, args: unknown[]): void => {
101
105
  if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[threshold]) return
@@ -1,7 +1,14 @@
1
- import { parseSegments } from '@snowluma/sdk'
1
+ import { parseSegments, type OneBotMessageEvent } from '@snowluma/sdk'
2
2
 
3
3
  export type CommandParamType =
4
- 'string' | 'text' | 'number' | 'integer' | 'boolean' | 'at'
4
+ | 'string'
5
+ | 'text'
6
+ | 'number'
7
+ | 'integer'
8
+ | 'boolean'
9
+ | 'at'
10
+ | 'reply'
11
+ | 'time'
5
12
 
6
13
  export interface ParsedCommandParams {
7
14
  args: unknown[]
@@ -10,7 +17,7 @@ export interface ParsedCommandParams {
10
17
 
11
18
  export interface CompiledCommandParams {
12
19
  usage: string
13
- parse(input: string): ParsedCommandParams
20
+ parse(input: string, event?: OneBotMessageEvent): ParsedCommandParams
14
21
  }
15
22
 
16
23
  export class CommandParamsError extends Error {
@@ -51,6 +58,8 @@ function parseType(value: string): CommandParamType {
51
58
  case 'integer':
52
59
  case 'boolean':
53
60
  case 'at':
61
+ case 'reply':
62
+ case 'time':
54
63
  return value
55
64
  default:
56
65
  throw declarationError(`未知的参数类型 "${value}"`)
@@ -209,6 +218,127 @@ function parseAt(value: string, name: string, usage: string): number {
209
218
  )
210
219
  }
211
220
 
221
+ const TIME_UNITS: Record<string, number> = {
222
+ s: 1,
223
+ 秒: 1,
224
+ m: 60,
225
+ 分: 60,
226
+ 分钟: 60,
227
+ h: 3600,
228
+ 时: 3600,
229
+ 小时: 3600,
230
+ d: 86400,
231
+ 天: 86400,
232
+ w: 604800,
233
+ 周: 604800,
234
+ }
235
+
236
+ const TIME_PART = /^(\d+(?:\.\d+)?)(分钟|小时|秒|分|时|天|周|[smhdw])/i
237
+
238
+ function parseTimeValue(value: string): number | null {
239
+ const raw = value.trim()
240
+
241
+ if (/^\d+$/.test(raw)) {
242
+ const seconds = Number(raw)
243
+
244
+ return Number.isSafeInteger(seconds) ? seconds : null
245
+ }
246
+
247
+ let index = 0
248
+ let total = 0
249
+ let parsed = false
250
+
251
+ while (index < raw.length) {
252
+ const match = TIME_PART.exec(raw.slice(index))
253
+
254
+ if (!match) return null
255
+
256
+ const unit = TIME_UNITS[match[2]?.toLowerCase() ?? '']
257
+ const amount = Number(match[1])
258
+
259
+ if (unit === undefined) return null
260
+
261
+ total += amount * unit
262
+ index += match[0].length
263
+ parsed = true
264
+ }
265
+
266
+ if (!parsed || !Number.isSafeInteger(total) || total < 0) {
267
+ return null
268
+ }
269
+
270
+ return total
271
+ }
272
+
273
+ function parseTime(value: string, name: string, usage: string): number {
274
+ const seconds = parseTimeValue(value)
275
+
276
+ if (seconds === null) {
277
+ throw new CommandParamsError(
278
+ `参数 ${name} 需要是时间,例如 10s、5m、1h30m`,
279
+ usage
280
+ )
281
+ }
282
+
283
+ return seconds
284
+ }
285
+
286
+ function isRecord(value: unknown): value is Record<string, unknown> {
287
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
288
+ }
289
+
290
+ function toMessageId(value: unknown): number | undefined {
291
+ const raw = typeof value === 'string' ? value.trim() : value
292
+
293
+ if (typeof raw !== 'number' && typeof raw !== 'string') {
294
+ return undefined
295
+ }
296
+
297
+ const parsed =
298
+ typeof raw === 'string'
299
+ ? /^-?\d+$/.test(raw)
300
+ ? Number(raw)
301
+ : Number.NaN
302
+ : raw
303
+
304
+ return Number.isSafeInteger(parsed) && parsed !== 0 ? parsed : undefined
305
+ }
306
+
307
+ function replyIdIn(segments: readonly unknown[]): number | undefined {
308
+ for (const segment of segments) {
309
+ if (!isRecord(segment) || segment.type !== 'reply') continue
310
+
311
+ const data = isRecord(segment.data) ? segment.data : undefined
312
+ const id = toMessageId(data?.id)
313
+
314
+ if (id !== undefined) return id
315
+ }
316
+
317
+ return undefined
318
+ }
319
+
320
+ function quoteIdOf(event: OneBotMessageEvent | undefined): number | undefined {
321
+ if (!event) return undefined
322
+
323
+ const message = event.message
324
+
325
+ if (typeof message === 'string') {
326
+ const id = replyIdIn(parseSegments(message))
327
+
328
+ if (id !== undefined) return id
329
+ } else if (Array.isArray(message)) {
330
+ const id = replyIdIn(message)
331
+
332
+ if (id !== undefined) return id
333
+ } else if (isRecord(message)) {
334
+ const id = replyIdIn([message])
335
+
336
+ if (id !== undefined) return id
337
+ }
338
+
339
+ return replyIdIn(parseSegments(event.raw_message))
340
+ }
341
+
212
342
  function parseDefaultValue(
213
343
  type: CommandParamType,
214
344
  raw: string,
@@ -248,7 +378,17 @@ function parseDefaultValue(
248
378
  return boolean
249
379
  }
250
380
  case 'at':
381
+ case 'reply':
251
382
  throw declarationError(`参数 ${name} 不支持默认值`)
383
+ case 'time': {
384
+ const seconds = parseTimeValue(value)
385
+
386
+ if (seconds === null) {
387
+ throw declarationError(`参数 ${name} 的默认值不是时间`)
388
+ }
389
+
390
+ return seconds
391
+ }
252
392
  }
253
393
  }
254
394
 
@@ -526,19 +666,37 @@ function parseValue(spec: ParamSpec, raw: string, usage: string): unknown {
526
666
  }
527
667
  case 'at':
528
668
  return parseAt(raw, spec.name, usage)
669
+ case 'time':
670
+ return parseTime(raw, spec.name, usage)
529
671
  }
530
672
  }
531
673
 
532
674
  function parseInput(
533
675
  specs: ParamSpec[],
534
676
  input: string,
535
- usage: string
677
+ usage: string,
678
+ quoteId: number | undefined
536
679
  ): ParsedCommandParams {
537
680
  const tokens = tokenizeInput(input, usage)
538
681
  const args: unknown[] = []
539
682
  let index = 0
540
683
 
541
684
  for (const spec of specs) {
685
+ if (spec.type === 'reply') {
686
+ if (quoteId !== undefined) {
687
+ args.push(quoteId)
688
+ } else if (spec.required) {
689
+ throw new CommandParamsError(
690
+ `参数 ${spec.name} 需要引用一条消息`,
691
+ usage
692
+ )
693
+ } else {
694
+ args.push(undefined)
695
+ }
696
+
697
+ continue
698
+ }
699
+
542
700
  if (spec.rest) {
543
701
  for (
544
702
  let tokenIndex = index;
@@ -634,8 +792,8 @@ export function compileCommandParams(
634
792
 
635
793
  return {
636
794
  usage,
637
- parse(input: string): ParsedCommandParams {
638
- return parseInput(specs, input, usage)
795
+ parse(input: string, event?: OneBotMessageEvent): ParsedCommandParams {
796
+ return parseInput(specs, input, usage, quoteIdOf(event))
639
797
  },
640
798
  }
641
799
  }
@@ -653,7 +811,7 @@ type Trim<Source extends string> = TrimLeft<TrimRight<Source>>
653
811
  type ParamValue<Type extends string> =
654
812
  Trim<Type> extends 'string' | 'text'
655
813
  ? string
656
- : Trim<Type> extends 'number' | 'integer' | 'at'
814
+ : Trim<Type> extends 'number' | 'integer' | 'at' | 'reply' | 'time'
657
815
  ? number
658
816
  : Trim<Type> extends 'boolean'
659
817
  ? boolean
@@ -26,6 +26,21 @@ export interface SessionStore {
26
26
  clear(sessionId: string): Promise<void>
27
27
  }
28
28
 
29
+ export interface SessionSummary {
30
+ sessionId: string
31
+ provider?: string | undefined
32
+ model?: string | undefined
33
+ persona?: string | undefined
34
+ messageCount: number
35
+ updatedAt: number
36
+ }
37
+
38
+ export interface SessionListOptions {
39
+ limit?: number | undefined
40
+ offset?: number | undefined
41
+ query?: string | undefined
42
+ }
43
+
29
44
  interface SessionRow {
30
45
  session_id: string
31
46
  provider: string | null
@@ -35,6 +50,15 @@ interface SessionRow {
35
50
  updated_at: number
36
51
  }
37
52
 
53
+ interface SessionSummaryRow {
54
+ session_id: string
55
+ provider: string | null
56
+ model: string | null
57
+ persona: string | null
58
+ message_count: number
59
+ updated_at: number
60
+ }
61
+
38
62
  function isSessionRole(value: unknown): value is SessionRole {
39
63
  return value === 'system' || value === 'user' || value === 'assistant'
40
64
  }
@@ -79,6 +103,29 @@ function toState(row: SessionRow): SessionState {
79
103
  }
80
104
  }
81
105
 
106
+ function toSummary(row: SessionSummaryRow): SessionSummary {
107
+ return {
108
+ sessionId: row.session_id,
109
+ ...(row.provider !== null ? { provider: row.provider } : {}),
110
+ ...(row.model !== null ? { model: row.model } : {}),
111
+ ...(row.persona !== null ? { persona: row.persona } : {}),
112
+ messageCount: row.message_count,
113
+ updatedAt: row.updated_at,
114
+ }
115
+ }
116
+
117
+ function clampLimit(value: number | undefined, fallback: number): number {
118
+ if (value === undefined || !Number.isFinite(value)) return fallback
119
+
120
+ return Math.min(Math.max(Math.trunc(value), 1), 500)
121
+ }
122
+
123
+ function clampOffset(value: number | undefined): number {
124
+ if (value === undefined || !Number.isFinite(value)) return 0
125
+
126
+ return Math.max(Math.trunc(value), 0)
127
+ }
128
+
82
129
  export class SqliteSessionStore implements SessionStore {
83
130
  constructor(private readonly database: Database) {}
84
131
 
@@ -167,4 +214,52 @@ export class SqliteSessionStore implements SessionStore {
167
214
  .prepare('DELETE FROM sessions WHERE session_id = ?')
168
215
  .run(sessionId)
169
216
  }
217
+
218
+ async list(options: SessionListOptions = {}): Promise<SessionSummary[]> {
219
+ const limit = clampLimit(options.limit, 50)
220
+ const offset = clampOffset(options.offset)
221
+ const query = options.query?.trim()
222
+
223
+ const rows = (query
224
+ ? this.database.connection
225
+ .prepare(
226
+ `SELECT session_id, provider, model, persona,
227
+ json_array_length(messages) AS message_count,
228
+ updated_at
229
+ FROM sessions
230
+ WHERE session_id LIKE ?
231
+ ORDER BY updated_at DESC
232
+ LIMIT ? OFFSET ?`
233
+ )
234
+ .all(`%${query}%`, limit, offset)
235
+ : this.database.connection
236
+ .prepare(
237
+ `SELECT session_id, provider, model, persona,
238
+ json_array_length(messages) AS message_count,
239
+ updated_at
240
+ FROM sessions
241
+ ORDER BY updated_at DESC
242
+ LIMIT ? OFFSET ?`
243
+ )
244
+ .all(limit, offset)) as unknown as SessionSummaryRow[]
245
+
246
+ return rows.map(toSummary)
247
+ }
248
+
249
+ async count(query?: string): Promise<number> {
250
+ const value = query?.trim()
251
+ const row = (
252
+ value
253
+ ? this.database.connection
254
+ .prepare(
255
+ 'SELECT COUNT(*) AS count FROM sessions WHERE session_id LIKE ?'
256
+ )
257
+ .get(`%${value}%`)
258
+ : this.database.connection
259
+ .prepare('SELECT COUNT(*) AS count FROM sessions')
260
+ .get()
261
+ ) as { count: number } | undefined
262
+
263
+ return row?.count ?? 0
264
+ }
170
265
  }