@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,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Tracker — Accumulates usage metrics per session.
|
|
3
|
+
*
|
|
4
|
+
* Fed by AgentEvent 'done' events from the Router. Tracks:
|
|
5
|
+
* - Cumulative token counts (input, output, cache read, cache write)
|
|
6
|
+
* - Message count and turn count
|
|
7
|
+
* - Session start time and duration
|
|
8
|
+
* - Last known model and context usage
|
|
9
|
+
* - Cost estimate (via model-catalog pricing)
|
|
10
|
+
*
|
|
11
|
+
* All data comes from the stream-json output that Claude Code CLI
|
|
12
|
+
* already provides — no internal APIs or OAuth tokens needed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentUsage } from '../types/agent'
|
|
16
|
+
import { calculateCost, getContextWindowSize, getModelDisplayName } from './model-catalog'
|
|
17
|
+
|
|
18
|
+
export interface SessionUsage {
|
|
19
|
+
/** Total input tokens across all queries */
|
|
20
|
+
inputTokens: number
|
|
21
|
+
/** Total output tokens across all queries */
|
|
22
|
+
outputTokens: number
|
|
23
|
+
/** Total cache read tokens */
|
|
24
|
+
cacheReadTokens: number
|
|
25
|
+
/** Total cache write tokens */
|
|
26
|
+
cacheWriteTokens: number
|
|
27
|
+
/** Number of agent queries completed */
|
|
28
|
+
queryCount: number
|
|
29
|
+
/** Number of user messages processed */
|
|
30
|
+
messageCount: number
|
|
31
|
+
/** Last known model ID */
|
|
32
|
+
model?: string
|
|
33
|
+
/** Context tokens from the last API call (actual context window usage) */
|
|
34
|
+
lastContextTokens: number
|
|
35
|
+
/** Session start timestamp (ms) */
|
|
36
|
+
startedAt: number
|
|
37
|
+
/** Total API duration across all queries (ms) */
|
|
38
|
+
totalApiDurationMs: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SessionStatus {
|
|
42
|
+
/** Session key */
|
|
43
|
+
sessionKey: string
|
|
44
|
+
/** Usage data */
|
|
45
|
+
usage: SessionUsage
|
|
46
|
+
/** Human-readable model name */
|
|
47
|
+
modelDisplayName: string
|
|
48
|
+
/** Context window max for current model */
|
|
49
|
+
contextWindowSize: number
|
|
50
|
+
/** Context usage percentage (0-100) */
|
|
51
|
+
contextUsedPercent: number
|
|
52
|
+
/** Estimated cost in USD */
|
|
53
|
+
estimatedCostUsd: number
|
|
54
|
+
/** Session age in human-readable format */
|
|
55
|
+
sessionAge: string
|
|
56
|
+
/** Session age in ms */
|
|
57
|
+
sessionAgeMs: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tracks usage metrics per session. In-memory — resets on restart.
|
|
62
|
+
* Designed to be lightweight: one Map, no async, no external deps.
|
|
63
|
+
*/
|
|
64
|
+
export class SessionTracker {
|
|
65
|
+
private sessions = new Map<string, SessionUsage>()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a completed agent query for a session.
|
|
69
|
+
* Called by Router when it receives an AgentEvent of type 'done'.
|
|
70
|
+
*/
|
|
71
|
+
recordQuery(sessionKey: string, usage: AgentUsage): void {
|
|
72
|
+
let session = this.sessions.get(sessionKey)
|
|
73
|
+
if (!session) {
|
|
74
|
+
session = {
|
|
75
|
+
inputTokens: 0,
|
|
76
|
+
outputTokens: 0,
|
|
77
|
+
cacheReadTokens: 0,
|
|
78
|
+
cacheWriteTokens: 0,
|
|
79
|
+
queryCount: 0,
|
|
80
|
+
messageCount: 0,
|
|
81
|
+
model: undefined,
|
|
82
|
+
lastContextTokens: 0,
|
|
83
|
+
startedAt: Date.now(),
|
|
84
|
+
totalApiDurationMs: 0,
|
|
85
|
+
}
|
|
86
|
+
this.sessions.set(sessionKey, session)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
session.inputTokens += usage.inputTokens
|
|
90
|
+
session.outputTokens += usage.outputTokens
|
|
91
|
+
session.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
92
|
+
session.cacheWriteTokens += usage.cacheWriteTokens ?? 0
|
|
93
|
+
session.queryCount += 1
|
|
94
|
+
// contextTokens = last API call's real context window usage
|
|
95
|
+
// Falls back to input + cache if not provided
|
|
96
|
+
session.lastContextTokens =
|
|
97
|
+
usage.contextTokens ??
|
|
98
|
+
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
99
|
+
session.totalApiDurationMs += usage.durationMs
|
|
100
|
+
|
|
101
|
+
if (usage.model) {
|
|
102
|
+
session.model = usage.model
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Record that a user message was processed (called per incoming message).
|
|
108
|
+
*/
|
|
109
|
+
recordMessage(sessionKey: string): void {
|
|
110
|
+
const session = this.sessions.get(sessionKey)
|
|
111
|
+
if (session) {
|
|
112
|
+
session.messageCount += 1
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get full status for a session.
|
|
118
|
+
*/
|
|
119
|
+
getStatus(sessionKey: string): SessionStatus | null {
|
|
120
|
+
const usage = this.sessions.get(sessionKey)
|
|
121
|
+
if (!usage) return null
|
|
122
|
+
|
|
123
|
+
const model = usage.model ?? 'unknown'
|
|
124
|
+
const contextWindowSize = getContextWindowSize(model)
|
|
125
|
+
|
|
126
|
+
// Context percentage from last API call's actual context window usage
|
|
127
|
+
const contextUsedPercent =
|
|
128
|
+
contextWindowSize > 0 ? Math.min(100, (usage.lastContextTokens / contextWindowSize) * 100) : 0
|
|
129
|
+
|
|
130
|
+
const estimatedCostUsd = calculateCost(model, {
|
|
131
|
+
inputTokens: usage.inputTokens,
|
|
132
|
+
outputTokens: usage.outputTokens,
|
|
133
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
134
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const ageMs = Date.now() - usage.startedAt
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
sessionKey,
|
|
141
|
+
usage,
|
|
142
|
+
modelDisplayName: getModelDisplayName(model),
|
|
143
|
+
contextWindowSize,
|
|
144
|
+
contextUsedPercent,
|
|
145
|
+
estimatedCostUsd,
|
|
146
|
+
sessionAge: formatDuration(ageMs),
|
|
147
|
+
sessionAgeMs: ageMs,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get raw usage for a session.
|
|
153
|
+
*/
|
|
154
|
+
getUsage(sessionKey: string): SessionUsage | undefined {
|
|
155
|
+
return this.sessions.get(sessionKey)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get all tracked sessions.
|
|
160
|
+
*/
|
|
161
|
+
listSessions(): string[] {
|
|
162
|
+
return [...this.sessions.keys()]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clear tracking for a session (e.g., on session close).
|
|
167
|
+
*/
|
|
168
|
+
clear(sessionKey: string): void {
|
|
169
|
+
this.sessions.delete(sessionKey)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clear all sessions.
|
|
174
|
+
*/
|
|
175
|
+
clearAll(): void {
|
|
176
|
+
this.sessions.clear()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Helpers ──────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Format milliseconds to human-readable duration.
|
|
184
|
+
* e.g., 7_380_000 → "2h 3m"
|
|
185
|
+
*/
|
|
186
|
+
export function formatDuration(ms: number): string {
|
|
187
|
+
const seconds = Math.floor(ms / 1000)
|
|
188
|
+
const minutes = Math.floor(seconds / 60)
|
|
189
|
+
const hours = Math.floor(minutes / 60)
|
|
190
|
+
|
|
191
|
+
if (hours > 0) {
|
|
192
|
+
const remainingMinutes = minutes % 60
|
|
193
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`
|
|
194
|
+
}
|
|
195
|
+
if (minutes > 0) {
|
|
196
|
+
const remainingSeconds = seconds % 60
|
|
197
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`
|
|
198
|
+
}
|
|
199
|
+
return `${seconds}s`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Format token count with K/M suffixes.
|
|
204
|
+
* e.g., 12450 → "12.4K", 1500000 → "1.5M", 850 → "850"
|
|
205
|
+
*/
|
|
206
|
+
export function formatTokens(tokens: number): string {
|
|
207
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
|
208
|
+
if (tokens >= 10_000) return `${(tokens / 1_000).toFixed(0)}K`
|
|
209
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
|
210
|
+
return String(tokens)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a progress bar string.
|
|
215
|
+
* e.g., buildProgressBar(42, 10) → "████░░░░░░"
|
|
216
|
+
*/
|
|
217
|
+
export function buildProgressBar(percent: number, width = 10): string {
|
|
218
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
219
|
+
const filled = Math.round((clamped / 100) * width)
|
|
220
|
+
const empty = width - filled
|
|
221
|
+
return '█'.repeat(filled) + '░'.repeat(empty)
|
|
222
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { log } from './logger'
|
|
5
|
+
|
|
6
|
+
export interface Session {
|
|
7
|
+
/** Channel + chat composite key */
|
|
8
|
+
key: string
|
|
9
|
+
/** Agent session ID (for resumption) */
|
|
10
|
+
agentSessionId: string
|
|
11
|
+
/** Last activity timestamp */
|
|
12
|
+
lastActive: number
|
|
13
|
+
/** Optional metadata */
|
|
14
|
+
metadata?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SessionStore {
|
|
18
|
+
private db: Database
|
|
19
|
+
|
|
20
|
+
constructor(dbPath: string) {
|
|
21
|
+
// Ensure directory exists
|
|
22
|
+
const dir = dirname(dbPath)
|
|
23
|
+
if (!existsSync(dir)) {
|
|
24
|
+
mkdirSync(dir, { recursive: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.db = new Database(dbPath, { create: true })
|
|
28
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
29
|
+
this.db.exec('PRAGMA busy_timeout = 5000')
|
|
30
|
+
this.migrate()
|
|
31
|
+
log.debug({ path: dbPath }, 'Session store initialized')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private migrate(): void {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
37
|
+
key TEXT PRIMARY KEY,
|
|
38
|
+
agent_session_id TEXT NOT NULL,
|
|
39
|
+
last_active INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
40
|
+
metadata TEXT
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
name TEXT,
|
|
46
|
+
schedule TEXT NOT NULL,
|
|
47
|
+
payload TEXT NOT NULL,
|
|
48
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
49
|
+
last_run INTEGER,
|
|
50
|
+
next_run INTEGER,
|
|
51
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(last_active);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_next ON jobs(next_run) WHERE enabled = 1;
|
|
56
|
+
`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get or create a session for a channel/chat key */
|
|
60
|
+
get(key: string): Session | null {
|
|
61
|
+
const row = this.db
|
|
62
|
+
.query('SELECT key, agent_session_id, last_active, metadata FROM sessions WHERE key = ?')
|
|
63
|
+
.get(key) as {
|
|
64
|
+
key: string
|
|
65
|
+
agent_session_id: string
|
|
66
|
+
last_active: number
|
|
67
|
+
metadata: string | null
|
|
68
|
+
} | null
|
|
69
|
+
|
|
70
|
+
if (!row) return null
|
|
71
|
+
return {
|
|
72
|
+
key: row.key,
|
|
73
|
+
agentSessionId: row.agent_session_id,
|
|
74
|
+
lastActive: row.last_active,
|
|
75
|
+
metadata: row.metadata ?? undefined,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Upsert a session */
|
|
80
|
+
set(key: string, agentSessionId: string, metadata?: string): void {
|
|
81
|
+
this.db
|
|
82
|
+
.query(
|
|
83
|
+
`INSERT INTO sessions (key, agent_session_id, last_active, metadata)
|
|
84
|
+
VALUES (?, ?, unixepoch(), ?)
|
|
85
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
86
|
+
agent_session_id = excluded.agent_session_id,
|
|
87
|
+
last_active = unixepoch(),
|
|
88
|
+
metadata = excluded.metadata`,
|
|
89
|
+
)
|
|
90
|
+
.run(key, agentSessionId, metadata ?? null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Touch session (update last_active) */
|
|
94
|
+
touch(key: string): void {
|
|
95
|
+
this.db.query('UPDATE sessions SET last_active = unixepoch() WHERE key = ?').run(key)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Delete a session */
|
|
99
|
+
delete(key: string): void {
|
|
100
|
+
this.db.query('DELETE FROM sessions WHERE key = ?').run(key)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** List all sessions, optionally filtered by recency */
|
|
104
|
+
list(maxAgeSeconds?: number): Session[] {
|
|
105
|
+
let query = 'SELECT key, agent_session_id, last_active, metadata FROM sessions'
|
|
106
|
+
const params: number[] = []
|
|
107
|
+
|
|
108
|
+
if (maxAgeSeconds) {
|
|
109
|
+
query += ' WHERE last_active > unixepoch() - ?'
|
|
110
|
+
params.push(maxAgeSeconds)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
query += ' ORDER BY last_active DESC'
|
|
114
|
+
|
|
115
|
+
const rows = this.db.query(query).all(...params) as Array<{
|
|
116
|
+
key: string
|
|
117
|
+
agent_session_id: string
|
|
118
|
+
last_active: number
|
|
119
|
+
metadata: string | null
|
|
120
|
+
}>
|
|
121
|
+
|
|
122
|
+
return rows.map((row) => ({
|
|
123
|
+
key: row.key,
|
|
124
|
+
agentSessionId: row.agent_session_id,
|
|
125
|
+
lastActive: row.last_active,
|
|
126
|
+
metadata: row.metadata ?? undefined,
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Update only metadata for an existing session (or create placeholder if none exists) */
|
|
131
|
+
updateMetadata(key: string, metadata: string): void {
|
|
132
|
+
this.db
|
|
133
|
+
.query(
|
|
134
|
+
`INSERT INTO sessions (key, agent_session_id, last_active, metadata)
|
|
135
|
+
VALUES (?, '', unixepoch(), ?)
|
|
136
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
137
|
+
metadata = excluded.metadata,
|
|
138
|
+
last_active = unixepoch()`,
|
|
139
|
+
)
|
|
140
|
+
.run(key, metadata)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Get parsed metadata for a session */
|
|
144
|
+
getMetadata(key: string): Record<string, unknown> | null {
|
|
145
|
+
const session = this.get(key)
|
|
146
|
+
if (!session?.metadata) return null
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(session.metadata) as Record<string, unknown>
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Close the database */
|
|
155
|
+
close(): void {
|
|
156
|
+
this.db.close()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillLoader — Discovers and indexes skills from the workspace filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Skills are directories containing a SKILL.md file with YAML frontmatter.
|
|
5
|
+
* The loader scans configured paths, extracts metadata, and builds a
|
|
6
|
+
* compact manifest for injection into the agent's system prompt.
|
|
7
|
+
*
|
|
8
|
+
* Design: filesystem IS the database. No SQLite, no cache invalidation.
|
|
9
|
+
* Skills are scanned once at startup and cached in memory.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir } from 'node:fs/promises'
|
|
13
|
+
import { join, resolve } from 'node:path'
|
|
14
|
+
import { log } from './logger'
|
|
15
|
+
|
|
16
|
+
export interface SkillMeta {
|
|
17
|
+
name: string
|
|
18
|
+
description: string
|
|
19
|
+
path: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SkillLoader {
|
|
23
|
+
private skills: SkillMeta[] = []
|
|
24
|
+
|
|
25
|
+
public get size(): number {
|
|
26
|
+
return this.skills.length
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public getSkills(): SkillMeta[] {
|
|
30
|
+
return [...this.skills]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public getManifest(allowlist?: string[]): string {
|
|
34
|
+
const skills = allowlist ? this.skills.filter((s) => allowlist.includes(s.name)) : this.skills
|
|
35
|
+
|
|
36
|
+
if (skills.length === 0) return ''
|
|
37
|
+
|
|
38
|
+
let manifest = '## Skills\n\nAvailable in your workspace. Read the SKILL.md before using.\n\n'
|
|
39
|
+
|
|
40
|
+
for (const skill of skills) {
|
|
41
|
+
// Find relative path to workspace if possible
|
|
42
|
+
let displayPath = skill.path
|
|
43
|
+
const workspaceIndex = skill.path.indexOf('/workspace/')
|
|
44
|
+
if (workspaceIndex !== -1) {
|
|
45
|
+
displayPath = skill.path.slice(workspaceIndex + 11)
|
|
46
|
+
} else {
|
|
47
|
+
const skillsIndex = skill.path.indexOf('skills/')
|
|
48
|
+
if (skillsIndex !== -1) {
|
|
49
|
+
displayPath = skill.path.slice(skillsIndex)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
manifest += `- **${skill.name}** — ${skill.description} → \`${displayPath}\`\n`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
manifest += '\nRead the relevant SKILL.md before executing specialized tasks.\n'
|
|
57
|
+
|
|
58
|
+
return manifest
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async scan(dirs: string[]): Promise<void> {
|
|
62
|
+
const foundSkills: SkillMeta[] = []
|
|
63
|
+
|
|
64
|
+
for (const dir of dirs) {
|
|
65
|
+
try {
|
|
66
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
const skillPath = join(dir, entry.name, 'SKILL.md')
|
|
70
|
+
const file = Bun.file(skillPath)
|
|
71
|
+
if (await file.exists()) {
|
|
72
|
+
const content = await file.text()
|
|
73
|
+
const meta = this.parseFrontmatter(content, skillPath)
|
|
74
|
+
if (meta) {
|
|
75
|
+
foundSkills.push(meta)
|
|
76
|
+
} else {
|
|
77
|
+
log.warn(
|
|
78
|
+
{ path: skillPath },
|
|
79
|
+
'Skipped SKILL.md due to missing or invalid frontmatter',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
if (err.code !== 'ENOENT') {
|
|
87
|
+
log.warn({ err: err.message, dir }, 'Failed to scan directory for skills')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort by name for deterministic output
|
|
93
|
+
foundSkills.sort((a, b) => a.name.localeCompare(b.name))
|
|
94
|
+
this.skills = foundSkills
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private parseFrontmatter(content: string, filePath: string): SkillMeta | null {
|
|
98
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
99
|
+
if (!match) return null
|
|
100
|
+
|
|
101
|
+
const yaml = match[1]
|
|
102
|
+
const lines = yaml.split(/\r?\n/)
|
|
103
|
+
let name = ''
|
|
104
|
+
let description = ''
|
|
105
|
+
|
|
106
|
+
let inMultilineDesc = false
|
|
107
|
+
let currentDesc = ''
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i]
|
|
111
|
+
if (!line.trim() && !inMultilineDesc) continue
|
|
112
|
+
|
|
113
|
+
if (!inMultilineDesc && line.includes(':')) {
|
|
114
|
+
const colonIndex = line.indexOf(':')
|
|
115
|
+
const key = line.slice(0, colonIndex).trim()
|
|
116
|
+
const value = line.slice(colonIndex + 1).trim()
|
|
117
|
+
|
|
118
|
+
if (key === 'name') {
|
|
119
|
+
name = this.stripQuotes(value)
|
|
120
|
+
} else if (key === 'description') {
|
|
121
|
+
if (
|
|
122
|
+
value === '' ||
|
|
123
|
+
value.startsWith('"') ||
|
|
124
|
+
(value.startsWith("'") && !value.endsWith('"') && !value.endsWith("'"))
|
|
125
|
+
) {
|
|
126
|
+
inMultilineDesc = true
|
|
127
|
+
currentDesc = this.stripQuotes(value)
|
|
128
|
+
} else {
|
|
129
|
+
description = this.stripQuotes(value)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else if (inMultilineDesc) {
|
|
133
|
+
if (line.includes(':') && !line.startsWith(' ')) {
|
|
134
|
+
inMultilineDesc = false
|
|
135
|
+
description = this.stripQuotes(currentDesc)
|
|
136
|
+
i-- // Re-process this line
|
|
137
|
+
} else {
|
|
138
|
+
currentDesc += (currentDesc ? ' ' : '') + line.trim()
|
|
139
|
+
if (currentDesc.endsWith('"') || currentDesc.endsWith("'")) {
|
|
140
|
+
inMultilineDesc = false
|
|
141
|
+
description = this.stripQuotes(currentDesc)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (inMultilineDesc && !description) {
|
|
148
|
+
description = this.stripQuotes(currentDesc)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!name || !description) return null
|
|
152
|
+
|
|
153
|
+
return { name, description, path: resolve(filePath) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private stripQuotes(val: string): string {
|
|
157
|
+
const result = val.trim()
|
|
158
|
+
if (result.startsWith('"') && result.endsWith('"')) {
|
|
159
|
+
return result.slice(1, -1)
|
|
160
|
+
}
|
|
161
|
+
if (result.startsWith("'") && result.endsWith("'")) {
|
|
162
|
+
return result.slice(1, -1)
|
|
163
|
+
}
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage API — Fetch account-level usage/quota from Anthropic.
|
|
3
|
+
*
|
|
4
|
+
* Reads the OAuth token from Claude Code's credentials file
|
|
5
|
+
* (~/.claude/.credentials.json) and calls the usage endpoint.
|
|
6
|
+
*
|
|
7
|
+
* Returns 5-hour and 7-day rolling window usage vs limits.
|
|
8
|
+
* This is the same data that Claude Code's /usage command shows.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: This directly calls Anthropic's API with the stored OAuth token.
|
|
11
|
+
* It's a pragmatic solution — same auth that Claude Code uses internally.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { resolve } from 'node:path'
|
|
17
|
+
import { log } from './logger'
|
|
18
|
+
|
|
19
|
+
const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
20
|
+
|
|
21
|
+
export interface UsageWindow {
|
|
22
|
+
/** Tokens used in this window */
|
|
23
|
+
used: number
|
|
24
|
+
/** Token limit for this window */
|
|
25
|
+
limit: number
|
|
26
|
+
/** Percentage used (0-100) */
|
|
27
|
+
percent: number
|
|
28
|
+
/** When the window resets (ISO string, if available) */
|
|
29
|
+
resetsAt?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AccountUsage {
|
|
33
|
+
/** 5-hour rolling window */
|
|
34
|
+
fiveHour: UsageWindow | null
|
|
35
|
+
/** 7-day rolling window */
|
|
36
|
+
sevenDay: UsageWindow | null
|
|
37
|
+
/** When the data was fetched */
|
|
38
|
+
fetchedAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read the OAuth token from Claude Code's credentials file.
|
|
43
|
+
*
|
|
44
|
+
* Looks in:
|
|
45
|
+
* 1. CLAUDE_CODE_OAUTH_TOKEN env var
|
|
46
|
+
* 2. ~/.claude/.credentials.json
|
|
47
|
+
*/
|
|
48
|
+
export function readOAuthToken(): string | null {
|
|
49
|
+
// 1. Environment variable (takes precedence)
|
|
50
|
+
const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
51
|
+
if (envToken) return envToken
|
|
52
|
+
|
|
53
|
+
// 2. Credentials file
|
|
54
|
+
const credPath = resolve(homedir(), '.claude', '.credentials.json')
|
|
55
|
+
if (!existsSync(credPath)) {
|
|
56
|
+
log.debug({ path: credPath }, 'Claude credentials file not found')
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(credPath, 'utf-8')
|
|
62
|
+
const creds = JSON.parse(raw)
|
|
63
|
+
|
|
64
|
+
// The file typically has { accessToken, refreshToken, expiresAt }
|
|
65
|
+
const token = creds.claudeAiOauth?.accessToken ?? creds.accessToken ?? creds.access_token
|
|
66
|
+
if (!token) {
|
|
67
|
+
log.debug('No access token found in credentials file')
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return token
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log.warn({ err }, 'Failed to read Claude credentials')
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a raw usage window object into a typed UsageWindow.
|
|
80
|
+
* Returns null if input is not a valid object.
|
|
81
|
+
*/
|
|
82
|
+
export function parseWindow(raw: unknown): UsageWindow | null {
|
|
83
|
+
if (!raw || typeof raw !== 'object') return null
|
|
84
|
+
const w = raw as Record<string, unknown>
|
|
85
|
+
const used = typeof w.used === 'number' ? w.used : 0
|
|
86
|
+
const limit = typeof w.limit === 'number' ? w.limit : 0
|
|
87
|
+
return {
|
|
88
|
+
used,
|
|
89
|
+
limit,
|
|
90
|
+
percent: limit > 0 ? Math.round((used / limit) * 100) : 0,
|
|
91
|
+
resetsAt: typeof w.resetsAt === 'string' ? w.resetsAt : undefined,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch account usage from Anthropic's OAuth usage API.
|
|
97
|
+
*
|
|
98
|
+
* Returns null if:
|
|
99
|
+
* - No OAuth token found
|
|
100
|
+
* - API call fails
|
|
101
|
+
* - Not an OAuth/subscription user (API key users don't have this)
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchAccountUsage(): Promise<AccountUsage | null> {
|
|
104
|
+
const token = readOAuthToken()
|
|
105
|
+
if (!token) return null
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(USAGE_API_URL, {
|
|
109
|
+
method: 'GET',
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: 'application/json',
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
Authorization: `Bearer ${token}`,
|
|
114
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
115
|
+
'User-Agent': 'lunar/1.0',
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
log.debug(
|
|
121
|
+
{ status: response.status, statusText: response.statusText },
|
|
122
|
+
'Usage API returned non-OK',
|
|
123
|
+
)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const data = (await response.json()) as Record<string, unknown>
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
fiveHour: parseWindow(data.five_hour),
|
|
131
|
+
sevenDay: parseWindow(data.seven_day),
|
|
132
|
+
fetchedAt: Date.now(),
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log.debug({ err }, 'Failed to fetch account usage')
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if OAuth credentials are available.
|
|
142
|
+
*/
|
|
143
|
+
export function hasOAuthCredentials(): boolean {
|
|
144
|
+
return readOAuthToken() !== null
|
|
145
|
+
}
|