@onmars/lunar-core 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,55 @@
1
+ import { createWriteStream, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import pino from 'pino'
4
+
5
+ export interface LoggerOptions {
6
+ level?: string
7
+ pretty?: boolean
8
+ name?: string
9
+ /** When set, logs are written to BOTH stdout and this file path */
10
+ logPath?: string
11
+ }
12
+
13
+ export function createLogger(options: LoggerOptions = {}): pino.Logger {
14
+ const { level = 'info', name = 'lunar', logPath } = options
15
+
16
+ // Bun doesn't support pino transports well — use basic pino
17
+ const pinoOpts: pino.LoggerOptions = {
18
+ name,
19
+ level,
20
+ // Readable timestamps in dev
21
+ timestamp: pino.stdTimeFunctions.isoTime,
22
+ }
23
+
24
+ if (logPath) {
25
+ // Ensure log directory exists
26
+ const dir = dirname(logPath)
27
+ mkdirSync(dir, { recursive: true })
28
+
29
+ const fileStream = createWriteStream(logPath, { flags: 'a' })
30
+ const streams = pino.multistream([
31
+ { stream: process.stdout, level: level as pino.Level },
32
+ { stream: fileStream, level: 'debug' as pino.Level },
33
+ ])
34
+ // Logger level must be the lowest of all stream levels so that
35
+ // debug messages reach the file even when stdout is at info.
36
+ pinoOpts.level = 'debug'
37
+ return pino.pino(pinoOpts, streams)
38
+ }
39
+
40
+ return pino(pinoOpts)
41
+ }
42
+
43
+ /** Mutable logger singleton — call reconfigureLogger() to update */
44
+ export let log: pino.Logger = createLogger()
45
+
46
+ /**
47
+ * Replace the shared logger instance with a new one.
48
+ *
49
+ * ESM `export let` creates a live binding — all modules that
50
+ * imported `{ log }` will see the new value on next access.
51
+ */
52
+ export function reconfigureLogger(options: LoggerOptions): pino.Logger {
53
+ log = createLogger(options)
54
+ return log
55
+ }
@@ -0,0 +1,415 @@
1
+ /**
2
+ * MemoryOrchestrator — Coordinates multiple MemoryProviders
3
+ *
4
+ * Implements MemoryProvider itself so it plugs into the existing Router
5
+ * as a single provider that internally manages many.
6
+ *
7
+ * Features:
8
+ * - Parallel recall across all providers
9
+ * - Deduplication of merged results (using dedup module)
10
+ * - Token budget enforcement
11
+ * - Lifecycle hooks (HookRunner) at every stage
12
+ * - Session lifecycle broadcast (onSessionEnd → all providers)
13
+ * - Health aggregation
14
+ * - Non-fatal: individual provider failures don't block others
15
+ */
16
+
17
+ import type { Fact, MemoryProvider, MemoryResult } from '../types/memory'
18
+ import type { HooksConfig } from './config-loader'
19
+ import { createStrategy, type DedupConfig, deduplicate } from './dedup'
20
+ import { type HookContext, HookRunner, type LLMFunction } from './hook-runner'
21
+ import { log } from './logger'
22
+
23
+ export interface OrchestratorConfig {
24
+ /** Max results after dedup. Default: 10 */
25
+ maxResults?: number
26
+ /** Dedup configuration */
27
+ dedup?: DedupConfig
28
+ /** Lifecycle hooks configuration (optional) */
29
+ hooks?: HooksConfig
30
+ /** Optional LLM function for intelligent hook actions (summarize, promote) */
31
+ llm?: LLMFunction
32
+ }
33
+
34
+ export class MemoryOrchestrator implements MemoryProvider {
35
+ readonly id = 'orchestrator'
36
+ readonly name = 'Memory Orchestrator'
37
+
38
+ private providers: MemoryProvider[] = []
39
+ private providersByName = new Map<string, MemoryProvider>()
40
+ private config: Required<Pick<OrchestratorConfig, 'maxResults' | 'dedup'>>
41
+ private strategy
42
+ private hookRunner?: HookRunner
43
+
44
+ constructor(config: OrchestratorConfig = {}) {
45
+ this.config = {
46
+ maxResults: config.maxResults ?? 10,
47
+ dedup: config.dedup ?? { strategy: 'adaptive', threshold: 0.85 },
48
+ }
49
+ this.strategy = createStrategy(this.config.dedup)
50
+
51
+ // HookRunner is created when hooks config is provided
52
+ // Providers are added later via addProvider(), so the runner
53
+ // is updated with the provider map after all providers are added.
54
+ if (config.hooks && Object.keys(config.hooks).length > 0) {
55
+ this.hookRunner = new HookRunner(config.hooks, this.providersByName, {
56
+ llm: config.llm,
57
+ })
58
+ log.info(
59
+ { events: this.hookRunner.activeEvents, hasLLM: !!config.llm },
60
+ 'HookRunner initialized',
61
+ )
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Add a provider to the orchestrator.
67
+ *
68
+ * @param provider - The memory provider
69
+ * @param configName - Optional config key name (defaults to provider.id)
70
+ */
71
+ addProvider(provider: MemoryProvider, configName?: string): void {
72
+ this.providers.push(provider)
73
+ this.providersByName.set(configName ?? provider.id, provider)
74
+
75
+ // Keep HookRunner's provider map in sync
76
+ if (this.hookRunner) {
77
+ this.hookRunner.updateProviders(this.providersByName)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Number of active providers.
83
+ */
84
+ get providerCount(): number {
85
+ return this.providers.length
86
+ }
87
+
88
+ /**
89
+ * List provider IDs.
90
+ */
91
+ get providerIds(): string[] {
92
+ return this.providers.map((p) => p.id)
93
+ }
94
+
95
+ /**
96
+ * Whether hooks are configured.
97
+ */
98
+ get hasHooks(): boolean {
99
+ return this.hookRunner !== undefined
100
+ }
101
+
102
+ /**
103
+ * Initialize all providers + run sessionStart hooks.
104
+ */
105
+ /** Episode ID for the current session (set by episode hook at sessionStart) */
106
+ private currentEpisodeId?: string
107
+ private currentSessionDate?: string
108
+ /** Diary results loaded at sessionStart — prepended to every recall */
109
+ private diaryResults: MemoryResult[] = []
110
+
111
+ async init(channel?: string): Promise<void> {
112
+ await Promise.allSettled(this.providers.map((p) => p.init?.() ?? Promise.resolve()))
113
+
114
+ // Run sessionStart hooks (episode creation, diary recall, context pre-loading)
115
+ if (this.hookRunner?.hasHooks('sessionStart')) {
116
+ const context: HookContext = {
117
+ messageCount: 0,
118
+ channel,
119
+ sessionDate: new Date().toISOString().split('T')[0],
120
+ }
121
+ const result = await this.hookRunner.run('sessionStart', context)
122
+ // Store episode info for session lifecycle
123
+ if (result.episodeId) {
124
+ this.currentEpisodeId = result.episodeId
125
+ this.currentSessionDate = result.sessionDate
126
+ log.info({ episodeId: result.episodeId }, 'Session linked to episode')
127
+ }
128
+ // Store diary results for injection into every recall
129
+ if (result.results?.length) {
130
+ this.diaryResults = result.results
131
+ log.info(
132
+ { count: this.diaryResults.length },
133
+ 'Diary context loaded — will prepend to all recalls',
134
+ )
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Get the current episode ID (if episode hook ran at sessionStart) */
140
+ get episodeId(): string | undefined {
141
+ return this.currentEpisodeId
142
+ }
143
+
144
+ /** Get the current session date */
145
+ get sessionDate(): string | undefined {
146
+ return this.currentSessionDate
147
+ }
148
+
149
+ /**
150
+ * Destroy all providers.
151
+ */
152
+ async destroy(): Promise<void> {
153
+ await Promise.allSettled(this.providers.map((p) => p.destroy?.() ?? Promise.resolve()))
154
+ }
155
+
156
+ /**
157
+ * Recall from ALL providers in parallel, deduplicate, and return merged results.
158
+ *
159
+ * Lifecycle:
160
+ * 1. beforeRecall hooks (can modify query)
161
+ * 2. Parallel recall from all providers
162
+ * 3. Merge, sort, filter, deduplicate
163
+ * 4. afterRecall hooks (can boost scores, modify results)
164
+ * 5. Return final results
165
+ *
166
+ * Individual provider failures are caught — other providers still contribute.
167
+ */
168
+ async recall(
169
+ query: string,
170
+ options?: { limit?: number; minScore?: number },
171
+ ): Promise<MemoryResult[]> {
172
+ const limit = options?.limit ?? this.config.maxResults
173
+ let activeQuery = query
174
+
175
+ // ── beforeRecall hooks ──
176
+ if (this.hookRunner?.hasHooks('beforeRecall')) {
177
+ const ctx: HookContext = { messageCount: 0, query: activeQuery }
178
+ await this.hookRunner.run('beforeRecall', ctx)
179
+ // Use modified query if hook changed it
180
+ if (ctx.query && ctx.query !== activeQuery) {
181
+ log.debug(
182
+ { original: activeQuery, modified: ctx.query },
183
+ 'beforeRecall: query modified by hook',
184
+ )
185
+ activeQuery = ctx.query
186
+ }
187
+ }
188
+
189
+ // ── Parallel recall from all providers ──
190
+ const allResults = await Promise.allSettled(
191
+ this.providers.map(async (provider) => {
192
+ try {
193
+ const results = await provider.recall(activeQuery, options)
194
+ // Tag results with provider source if not already tagged
195
+ return results.map((r) => ({
196
+ ...r,
197
+ source: r.source || provider.id,
198
+ }))
199
+ } catch {
200
+ // Non-fatal — skip this provider
201
+ return [] as MemoryResult[]
202
+ }
203
+ }),
204
+ )
205
+
206
+ // Flatten results from all providers
207
+ const flat: MemoryResult[] = []
208
+ for (const result of allResults) {
209
+ if (result.status === 'fulfilled') {
210
+ flat.push(...result.value)
211
+ }
212
+ }
213
+
214
+ // Sort by score (highest first)
215
+ flat.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
216
+
217
+ // Filter by minimum score
218
+ const minScore = options?.minScore ?? 0
219
+ const filtered = minScore > 0 ? flat.filter((r) => (r.score ?? 0) >= minScore) : flat
220
+
221
+ // Deduplicate
222
+ const { kept } = deduplicate(filtered, this.strategy, {
223
+ threshold: this.config.dedup.threshold ?? 0.85,
224
+ maxResults: limit,
225
+ textExtractor: (item: unknown) => (item as MemoryResult).content,
226
+ })
227
+
228
+ // ── afterRecall hooks (can boost scores, modify results) ──
229
+ if (this.hookRunner?.hasHooks('afterRecall')) {
230
+ const ctx: HookContext = { messageCount: 0, results: kept }
231
+ await this.hookRunner.run('afterRecall', ctx)
232
+ const afterResults = ctx.results ?? kept
233
+ // Prepend diary context (always first, deduplicated)
234
+ return this.prependDiary(afterResults)
235
+ }
236
+
237
+ // Prepend diary context (always first, deduplicated)
238
+ return this.prependDiary(kept)
239
+ }
240
+
241
+ /**
242
+ * Save facts to ALL providers that support it.
243
+ *
244
+ * Lifecycle:
245
+ * 1. beforeSave hooks (can modify/classify facts)
246
+ * 2. Parallel save to all providers
247
+ * 3. afterSave hooks (can trigger cross-promotion)
248
+ */
249
+ async save(facts: Array<{ content: string; [key: string]: unknown }>): Promise<void> {
250
+ let activeFacts = facts as Fact[]
251
+
252
+ // Auto-inject episodeId and sessionDate into all facts
253
+ if (this.currentEpisodeId || this.currentSessionDate) {
254
+ activeFacts = activeFacts.map((f) => ({
255
+ ...f,
256
+ ...(!f.episodeId && this.currentEpisodeId ? { episodeId: this.currentEpisodeId } : {}),
257
+ ...(!f.sessionDate && this.currentSessionDate
258
+ ? { sessionDate: this.currentSessionDate }
259
+ : {}),
260
+ })) as Fact[]
261
+ }
262
+
263
+ // ── beforeSave hooks ──
264
+ if (this.hookRunner?.hasHooks('beforeSave')) {
265
+ const ctx: HookContext = { messageCount: 0, facts: activeFacts }
266
+ await this.hookRunner.run('beforeSave', ctx)
267
+ activeFacts = ctx.facts ?? activeFacts
268
+ }
269
+
270
+ // ── Parallel save ──
271
+ await Promise.allSettled(this.providers.map((p) => p.save?.(activeFacts) ?? Promise.resolve()))
272
+
273
+ // ── afterSave hooks ──
274
+ if (this.hookRunner?.hasHooks('afterSave')) {
275
+ const ctx: HookContext = { messageCount: 0, facts: activeFacts }
276
+ await this.hookRunner.run('afterSave', ctx)
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Broadcast session end to ALL providers + run lifecycle hooks.
282
+ *
283
+ * Lifecycle:
284
+ * 1. sessionEnd hooks (summarize → saves summary to provider)
285
+ * 2. onSessionEnd → all providers (each decides what to do)
286
+ * 3. afterSessionEnd hooks (promote → Engram facts → Brain)
287
+ */
288
+ async onSessionEnd(context: {
289
+ messages: Array<{ role: string; content: string }>
290
+ sessionSummary?: string
291
+ topics?: string[]
292
+ episodeId?: string
293
+ sessionDate?: string
294
+ channel?: string
295
+ }): Promise<void> {
296
+ const messageCount = context.messages.length
297
+
298
+ // ── sessionEnd hooks (e.g., summarize, journal, experience) ──
299
+ if (this.hookRunner?.hasHooks('sessionEnd')) {
300
+ const ctx: HookContext = {
301
+ messageCount,
302
+ messages: context.messages,
303
+ sessionSummary: context.sessionSummary,
304
+ topics: context.topics,
305
+ episodeId: context.episodeId,
306
+ sessionDate: context.sessionDate,
307
+ channel: context.channel,
308
+ createdFactIds: [],
309
+ }
310
+ const result = await this.hookRunner.run('sessionEnd', ctx)
311
+ // Pass updated values to downstream
312
+ if (result.sessionSummary) {
313
+ context.sessionSummary = result.sessionSummary
314
+ }
315
+ // Carry episodeId and createdFactIds forward
316
+ if (result.episodeId) context.episodeId = result.episodeId
317
+ }
318
+
319
+ // ── Broadcast to all providers ──
320
+ await Promise.allSettled(
321
+ this.providers.map((p) => p.onSessionEnd?.(context) ?? Promise.resolve()),
322
+ )
323
+
324
+ // ── afterSessionEnd hooks (e.g., reconcile, episodeClose) ──
325
+ if (this.hookRunner?.hasHooks('afterSessionEnd')) {
326
+ const ctx: HookContext = {
327
+ messageCount,
328
+ messages: context.messages,
329
+ sessionSummary: context.sessionSummary,
330
+ topics: context.topics,
331
+ episodeId: context.episodeId,
332
+ sessionDate: context.sessionDate,
333
+ channel: context.channel,
334
+ createdFactIds: [],
335
+ }
336
+ await this.hookRunner.run('afterSessionEnd', ctx)
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Prepend diary results to recall output (deduplicating by content).
342
+ * Diary entries always come first with their original high scores.
343
+ */
344
+ private prependDiary(results: MemoryResult[]): MemoryResult[] {
345
+ if (this.diaryResults.length === 0) return results
346
+
347
+ // Build a set of diary content for dedup
348
+ const diaryContent = new Set(this.diaryResults.map((r) => r.content))
349
+
350
+ // Filter out any recall results that duplicate diary content
351
+ const filtered = results.filter((r) => !diaryContent.has(r.content))
352
+
353
+ return [...this.diaryResults, ...filtered]
354
+ }
355
+
356
+ /**
357
+ * Aggregate health from all providers.
358
+ */
359
+ async health(): Promise<{ ok: boolean; error?: string }> {
360
+ const results = await Promise.allSettled(
361
+ this.providers.map(async (p) => {
362
+ const h = await p.health()
363
+ return { id: p.id, ...h }
364
+ }),
365
+ )
366
+
367
+ const statuses = results
368
+ .filter(
369
+ (r): r is PromiseFulfilledResult<{ id: string; ok: boolean; error?: string }> =>
370
+ r.status === 'fulfilled',
371
+ )
372
+ .map((r) => r.value)
373
+
374
+ const allOk = statuses.every((s) => s.ok)
375
+ const errors = statuses.filter((s) => !s.ok).map((s) => `${s.id}: ${s.error}`)
376
+
377
+ return {
378
+ ok: allOk,
379
+ error: errors.length > 0 ? errors.join('; ') : undefined,
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Merge agent instructions from all providers.
385
+ * Includes current episodeId for agents that save facts directly.
386
+ */
387
+ agentInstructions(): string {
388
+ const providerInstructions = this.providers
389
+ .map((p) => p.agentInstructions?.() ?? '')
390
+ .filter(Boolean)
391
+ .join('\n\n')
392
+
393
+ // Inject episodeId info for direct API usage
394
+ const episodeInfo = this.currentEpisodeId
395
+ ? [
396
+ '',
397
+ '### Current Session',
398
+ `- **Episode ID**: \`${this.currentEpisodeId}\``,
399
+ `- **Session Date**: \`${this.currentSessionDate}\``,
400
+ '- Include `"episodeId": "<id>"` and `"sessionDate": "<date>"` in all POST /facts calls.',
401
+ ].join('\n')
402
+ : ''
403
+
404
+ return providerInstructions + episodeInfo
405
+ }
406
+
407
+ /**
408
+ * Trigger compaction hooks (called when context window is compacted).
409
+ */
410
+ async onCompaction(context: HookContext): Promise<void> {
411
+ if (this.hookRunner?.hasHooks('compaction')) {
412
+ await this.hookRunner.run('compaction', context)
413
+ }
414
+ }
415
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Model Catalog — Static metadata for known AI models.
3
+ *
4
+ * Provides context window sizes, pricing, and display names.
5
+ * Used by SessionTracker and /status command to calculate
6
+ * context usage percentages and cost estimates.
7
+ *
8
+ * Pricing is per million tokens (USD). Context is in tokens.
9
+ * Data source: Anthropic pricing pages, last updated 2026-03.
10
+ */
11
+
12
+ export interface ModelInfo {
13
+ /** Human-readable display name */
14
+ displayName: string
15
+ /** Max context window in tokens */
16
+ contextWindow: number
17
+ /** Max output tokens */
18
+ maxOutput: number
19
+ /** Input price per million tokens (USD) */
20
+ inputPricePerM: number
21
+ /** Output price per million tokens (USD) */
22
+ outputPricePerM: number
23
+ /** Cache read price per million tokens (USD) — typically 10% of input */
24
+ cacheReadPricePerM: number
25
+ /** Cache write price per million tokens (USD) — typically 25% more than input */
26
+ cacheWritePricePerM: number
27
+ }
28
+
29
+ /**
30
+ * Known model catalog.
31
+ *
32
+ * Keys are normalized: we strip date suffixes and match on the base name.
33
+ * e.g., "claude-sonnet-4-5-20250514" → matches "claude-sonnet-4-5"
34
+ */
35
+ const CATALOG: Record<string, ModelInfo> = {
36
+ // ═══════════════════════════════════════════
37
+ // Claude 4.x family
38
+ // ═══════════════════════════════════════════
39
+
40
+ 'claude-sonnet-4-5': {
41
+ displayName: 'Claude Sonnet 4.5',
42
+ contextWindow: 200_000,
43
+ maxOutput: 16_384,
44
+ inputPricePerM: 3.0,
45
+ outputPricePerM: 15.0,
46
+ cacheReadPricePerM: 0.3,
47
+ cacheWritePricePerM: 3.75,
48
+ },
49
+
50
+ 'claude-sonnet-4-6': {
51
+ displayName: 'Claude Sonnet 4.6',
52
+ contextWindow: 200_000,
53
+ maxOutput: 16_384,
54
+ inputPricePerM: 3.0,
55
+ outputPricePerM: 15.0,
56
+ cacheReadPricePerM: 0.3,
57
+ cacheWritePricePerM: 3.75,
58
+ },
59
+
60
+ 'claude-sonnet-4-6[1m]': {
61
+ displayName: 'Claude Sonnet 4.6 (1M)',
62
+ contextWindow: 1_000_000,
63
+ maxOutput: 128_000,
64
+ inputPricePerM: 6.0,
65
+ outputPricePerM: 22.5,
66
+ cacheReadPricePerM: 0.6,
67
+ cacheWritePricePerM: 7.5,
68
+ },
69
+
70
+ 'claude-opus-4-5': {
71
+ displayName: 'Claude Opus 4.5',
72
+ contextWindow: 200_000,
73
+ maxOutput: 32_768,
74
+ inputPricePerM: 15.0,
75
+ outputPricePerM: 75.0,
76
+ cacheReadPricePerM: 1.5,
77
+ cacheWritePricePerM: 18.75,
78
+ },
79
+
80
+ 'claude-opus-4-6': {
81
+ displayName: 'Claude Opus 4.6',
82
+ contextWindow: 200_000,
83
+ maxOutput: 32_768,
84
+ inputPricePerM: 15.0,
85
+ outputPricePerM: 75.0,
86
+ cacheReadPricePerM: 1.5,
87
+ cacheWritePricePerM: 18.75,
88
+ },
89
+
90
+ // 1M context variant — same base model, extended window.
91
+ // Pricing: 2x input, 1.5x output for tokens beyond 200K.
92
+ // We use the premium rate as worst-case for cost estimates.
93
+ 'claude-opus-4-6[1m]': {
94
+ displayName: 'Claude Opus 4.6 (1M)',
95
+ contextWindow: 1_000_000,
96
+ maxOutput: 128_000,
97
+ inputPricePerM: 30.0,
98
+ outputPricePerM: 112.5,
99
+ cacheReadPricePerM: 3.0,
100
+ cacheWritePricePerM: 37.5,
101
+ },
102
+
103
+ 'claude-haiku-4-5': {
104
+ displayName: 'Claude Haiku 4.5',
105
+ contextWindow: 200_000,
106
+ maxOutput: 16_384,
107
+ inputPricePerM: 0.8,
108
+ outputPricePerM: 4.0,
109
+ cacheReadPricePerM: 0.08,
110
+ cacheWritePricePerM: 1.0,
111
+ },
112
+
113
+ // ═══════════════════════════════════════════
114
+ // Claude 3.x legacy
115
+ // ═══════════════════════════════════════════
116
+
117
+ 'claude-3-5-sonnet': {
118
+ displayName: 'Claude 3.5 Sonnet',
119
+ contextWindow: 200_000,
120
+ maxOutput: 8_192,
121
+ inputPricePerM: 3.0,
122
+ outputPricePerM: 15.0,
123
+ cacheReadPricePerM: 0.3,
124
+ cacheWritePricePerM: 3.75,
125
+ },
126
+
127
+ 'claude-3-opus': {
128
+ displayName: 'Claude 3 Opus',
129
+ contextWindow: 200_000,
130
+ maxOutput: 4_096,
131
+ inputPricePerM: 15.0,
132
+ outputPricePerM: 75.0,
133
+ cacheReadPricePerM: 1.5,
134
+ cacheWritePricePerM: 18.75,
135
+ },
136
+
137
+ 'claude-3-haiku': {
138
+ displayName: 'Claude 3 Haiku',
139
+ contextWindow: 200_000,
140
+ maxOutput: 4_096,
141
+ inputPricePerM: 0.25,
142
+ outputPricePerM: 1.25,
143
+ cacheReadPricePerM: 0.03,
144
+ cacheWritePricePerM: 0.3,
145
+ },
146
+ }
147
+
148
+ /** Default model info for unknown models */
149
+ const DEFAULT_MODEL: ModelInfo = {
150
+ displayName: 'Unknown Model',
151
+ contextWindow: 200_000,
152
+ maxOutput: 16_384,
153
+ inputPricePerM: 3.0,
154
+ outputPricePerM: 15.0,
155
+ cacheReadPricePerM: 0.3,
156
+ cacheWritePricePerM: 3.75,
157
+ }
158
+
159
+ /**
160
+ * Normalize a model ID by stripping the date suffix.
161
+ * Preserves bracket suffixes like [1m] for extended context variants.
162
+ *
163
+ * Examples:
164
+ * "claude-sonnet-4-5-20250514" → "claude-sonnet-4-5"
165
+ * "claude-opus-4-6-20260301" → "claude-opus-4-6"
166
+ * "claude-opus-4-6-20260301[1m]" → "claude-opus-4-6[1m]"
167
+ * "claude-opus-4-6[1m]" → "claude-opus-4-6[1m]"
168
+ */
169
+ function normalizeModelId(modelId: string): string {
170
+ // Separate bracket suffix (e.g., [1m]) before normalizing
171
+ const bracketMatch = modelId.match(/(\[[^\]]+\])$/)
172
+ const suffix = bracketMatch ? bracketMatch[1] : ''
173
+ const base = suffix ? modelId.slice(0, -suffix.length) : modelId
174
+
175
+ return base.replace(/-\d{8}$/, '') + suffix
176
+ }
177
+
178
+ /**
179
+ * Look up model info. Tries exact match first, then normalized (without date suffix).
180
+ * Falls back to DEFAULT_MODEL for unknown models.
181
+ */
182
+ export function getModelInfo(modelId: string): ModelInfo {
183
+ if (CATALOG[modelId]) return CATALOG[modelId]
184
+
185
+ const normalized = normalizeModelId(modelId)
186
+ if (CATALOG[normalized]) return CATALOG[normalized]
187
+
188
+ for (const [key, info] of Object.entries(CATALOG)) {
189
+ if (normalized.startsWith(key)) return info
190
+ }
191
+
192
+ return { ...DEFAULT_MODEL, displayName: modelId }
193
+ }
194
+
195
+ /**
196
+ * Get display name for a model ID.
197
+ */
198
+ export function getModelDisplayName(modelId: string): string {
199
+ return getModelInfo(modelId).displayName
200
+ }
201
+
202
+ /**
203
+ * Get context window size for a model ID.
204
+ */
205
+ export function getContextWindowSize(modelId: string): number {
206
+ return getModelInfo(modelId).contextWindow
207
+ }
208
+
209
+ /**
210
+ * Calculate cost in USD from token counts.
211
+ */
212
+ export function calculateCost(
213
+ modelId: string,
214
+ tokens: {
215
+ inputTokens: number
216
+ outputTokens: number
217
+ cacheReadTokens?: number
218
+ cacheWriteTokens?: number
219
+ },
220
+ ): number {
221
+ const info = getModelInfo(modelId)
222
+ const { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0 } = tokens
223
+
224
+ // Regular input tokens = total input minus cache tokens
225
+ const regularInput = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
226
+
227
+ return (
228
+ (regularInput / 1_000_000) * info.inputPricePerM +
229
+ (outputTokens / 1_000_000) * info.outputPricePerM +
230
+ (cacheReadTokens / 1_000_000) * info.cacheReadPricePerM +
231
+ (cacheWriteTokens / 1_000_000) * info.cacheWritePricePerM
232
+ )
233
+ }
234
+
235
+ /**
236
+ * List all known model IDs.
237
+ */
238
+ export function listModels(): string[] {
239
+ return Object.keys(CATALOG)
240
+ }