@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
}
|