@ainyc/canonry 1.0.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.
@@ -0,0 +1,45 @@
1
+ import { loadConfig } from '../config.js'
2
+ import { ApiClient } from '../client.js'
3
+
4
+ function getClient(): ApiClient {
5
+ const config = loadConfig()
6
+ return new ApiClient(config.apiUrl, config.apiKey)
7
+ }
8
+
9
+ export async function setProvider(name: string, opts: { apiKey?: string; baseUrl?: string; model?: string }): Promise<void> {
10
+ const client = getClient()
11
+ const result = await client.updateProvider(name, opts) as {
12
+ name: string
13
+ model?: string
14
+ configured: boolean
15
+ }
16
+ console.log(`Provider ${result.name} updated successfully.`)
17
+ if (result.model) {
18
+ console.log(` Model: ${result.model}`)
19
+ }
20
+ }
21
+
22
+ export async function showSettings(): Promise<void> {
23
+ const client = getClient()
24
+ const settings = await client.getSettings() as {
25
+ providers: Array<{
26
+ name: string
27
+ model?: string
28
+ configured: boolean
29
+ quota?: { maxConcurrency: number; maxRequestsPerMinute: number; maxRequestsPerDay: number }
30
+ }>
31
+ }
32
+
33
+ console.log('Provider settings:\n')
34
+
35
+ for (const provider of settings.providers) {
36
+ const status = provider.configured ? 'configured' : 'not configured'
37
+ console.log(` ${provider.name.padEnd(10)} ${status}`)
38
+ if (provider.configured) {
39
+ console.log(` Model: ${provider.model ?? '(default)'}`)
40
+ if (provider.quota) {
41
+ console.log(` Quota: ${provider.quota.maxConcurrency} concurrent · ${provider.quota.maxRequestsPerMinute}/min · ${provider.quota.maxRequestsPerDay}/day`)
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,52 @@
1
+ import { loadConfig } from '../config.js'
2
+ import { ApiClient } from '../client.js'
3
+
4
+ function getClient(): ApiClient {
5
+ const config = loadConfig()
6
+ return new ApiClient(config.apiUrl, config.apiKey)
7
+ }
8
+
9
+ export async function showStatus(project: string): Promise<void> {
10
+ const client = getClient()
11
+ const projectData = await client.getProject(project) as {
12
+ id: string
13
+ name: string
14
+ displayName: string
15
+ canonicalDomain: string
16
+ country: string
17
+ language: string
18
+ }
19
+
20
+ console.log(`Status: ${projectData.displayName} (${projectData.name})\n`)
21
+ console.log(` Domain: ${projectData.canonicalDomain}`)
22
+ console.log(` Country: ${projectData.country}`)
23
+ console.log(` Language: ${projectData.language}`)
24
+
25
+ // Try to get latest run info
26
+ try {
27
+ const runs = await client.listRuns(project) as Array<{
28
+ id: string
29
+ status: string
30
+ kind: string
31
+ createdAt: string
32
+ finishedAt: string | null
33
+ }>
34
+
35
+ if (runs.length > 0) {
36
+ const latest = runs[runs.length - 1]!
37
+ console.log(`\n Latest run:`)
38
+ console.log(` ID: ${latest.id}`)
39
+ console.log(` Status: ${latest.status}`)
40
+ console.log(` Created: ${latest.createdAt}`)
41
+ if (latest.finishedAt) {
42
+ console.log(` Finished: ${latest.finishedAt}`)
43
+ }
44
+ console.log(`\n Total runs: ${runs.length}`)
45
+ } else {
46
+ console.log('\n No runs yet. Use "canonry run" to trigger one.')
47
+ }
48
+ } catch {
49
+ // Runs endpoint may not be available
50
+ console.log('\n Run info unavailable.')
51
+ }
52
+ }
package/src/config.ts ADDED
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { parse, stringify } from 'yaml'
5
+ import type { ProviderQuotaPolicy } from '@ainyc/canonry-contracts'
6
+
7
+ export interface ProviderConfigEntry {
8
+ apiKey?: string
9
+ baseUrl?: string
10
+ model?: string
11
+ quota?: ProviderQuotaPolicy
12
+ }
13
+
14
+ export interface CanonryConfig {
15
+ apiUrl: string
16
+ database: string
17
+ apiKey: string
18
+ port?: number
19
+ // Legacy single-provider fields (backward compat)
20
+ geminiApiKey?: string
21
+ geminiModel?: string
22
+ geminiQuota?: ProviderQuotaPolicy
23
+ // Multi-provider config
24
+ providers?: {
25
+ gemini?: ProviderConfigEntry
26
+ openai?: ProviderConfigEntry
27
+ claude?: ProviderConfigEntry
28
+ local?: ProviderConfigEntry
29
+ }
30
+ }
31
+
32
+ export function getConfigDir(): string {
33
+ return path.join(os.homedir(), '.canonry')
34
+ }
35
+
36
+ export function getConfigPath(): string {
37
+ return path.join(getConfigDir(), 'config.yaml')
38
+ }
39
+
40
+ export function loadConfig(): CanonryConfig {
41
+ const configPath = getConfigPath()
42
+ if (!fs.existsSync(configPath)) {
43
+ throw new Error(
44
+ `Config not found at ${configPath}. Run "canonry init" to create one.`,
45
+ )
46
+ }
47
+ const raw = fs.readFileSync(configPath, 'utf-8')
48
+ const parsed = parse(raw) as CanonryConfig
49
+ if (!parsed.apiUrl || !parsed.database || !parsed.apiKey) {
50
+ throw new Error(
51
+ `Invalid config at ${configPath}. Required fields: apiUrl, database, apiKey`,
52
+ )
53
+ }
54
+
55
+ // Migrate legacy geminiApiKey to providers map
56
+ if (parsed.geminiApiKey && !parsed.providers?.gemini) {
57
+ parsed.providers = {
58
+ ...parsed.providers,
59
+ gemini: {
60
+ apiKey: parsed.geminiApiKey,
61
+ model: parsed.geminiModel,
62
+ quota: parsed.geminiQuota,
63
+ },
64
+ }
65
+ }
66
+
67
+ // Must have at least one provider configured
68
+ const hasProvider = parsed.providers &&
69
+ (parsed.providers.gemini?.apiKey || parsed.providers.openai?.apiKey || parsed.providers.claude?.apiKey || parsed.providers.local?.baseUrl)
70
+ if (!hasProvider) {
71
+ throw new Error(
72
+ `No providers configured at ${configPath}. At least one provider is required.`,
73
+ )
74
+ }
75
+
76
+ return parsed
77
+ }
78
+
79
+ export function saveConfig(config: CanonryConfig): void {
80
+ const configDir = getConfigDir()
81
+ if (!fs.existsSync(configDir)) {
82
+ fs.mkdirSync(configDir, { recursive: true })
83
+ }
84
+ const yaml = stringify(config)
85
+ fs.writeFileSync(getConfigPath(), yaml, { encoding: 'utf-8', mode: 0o600 })
86
+ }
87
+
88
+ export function configExists(): boolean {
89
+ return fs.existsSync(getConfigPath())
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createServer } from './server.js'
2
+ export { loadConfig, type CanonryConfig } from './config.js'
@@ -0,0 +1,368 @@
1
+ import crypto from 'node:crypto'
2
+ import { eq } from 'drizzle-orm'
3
+ import type { DatabaseClient } from '@ainyc/canonry-db'
4
+ import { runs, keywords, competitors, projects, querySnapshots, usageCounters } from '@ainyc/canonry-db'
5
+ import type { ProviderName, NormalizedQueryResult } from '@ainyc/canonry-contracts'
6
+ import type { ProviderRegistry, RegisteredProvider } from './provider-registry.js'
7
+
8
+ export class JobRunner {
9
+ private db: DatabaseClient
10
+ private registry: ProviderRegistry
11
+ onRunCompleted?: (runId: string, projectId: string) => Promise<void>
12
+
13
+ constructor(db: DatabaseClient, registry: ProviderRegistry) {
14
+ this.db = db
15
+ this.registry = registry
16
+ }
17
+
18
+ async executeRun(runId: string, projectId: string, providerOverride?: ProviderName[]): Promise<void> {
19
+ const now = new Date().toISOString()
20
+
21
+ try {
22
+ // Mark run as running
23
+ this.db
24
+ .update(runs)
25
+ .set({ status: 'running', startedAt: now })
26
+ .where(eq(runs.id, runId))
27
+ .run()
28
+
29
+ // Fetch project
30
+ const project = this.db
31
+ .select()
32
+ .from(projects)
33
+ .where(eq(projects.id, projectId))
34
+ .get()
35
+
36
+ if (!project) {
37
+ throw new Error(`Project ${projectId} not found`)
38
+ }
39
+
40
+ // Resolve which providers to use — honour per-run override, then project config
41
+ const projectProviders = providerOverride ?? (JSON.parse(project.providers || '[]') as ProviderName[])
42
+ const activeProviders = this.registry.getForProject(projectProviders)
43
+
44
+ if (activeProviders.length === 0) {
45
+ throw new Error('No providers configured. Add at least one provider API key.')
46
+ }
47
+
48
+ console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map(p => p.adapter.name).join(', ')}`)
49
+
50
+ // Fetch keywords for the project
51
+ const projectKeywords = this.db
52
+ .select()
53
+ .from(keywords)
54
+ .where(eq(keywords.projectId, projectId))
55
+ .all()
56
+
57
+ // Fetch competitors for the project
58
+ const projectCompetitors = this.db
59
+ .select()
60
+ .from(competitors)
61
+ .where(eq(competitors.projectId, projectId))
62
+ .all()
63
+
64
+ const competitorDomains = projectCompetitors.map(c => c.domain)
65
+
66
+ // Enforce daily quota per provider — each provider receives one query per keyword.
67
+ // Track and check usage per (projectId, providerName) so that a provider that has
68
+ // never been used isn't blocked by another provider's past usage.
69
+ const queriesPerProvider = projectKeywords.length
70
+ const todayPeriod = getCurrentPeriod()
71
+
72
+ for (const p of activeProviders) {
73
+ const providerScope = `${projectId}:${p.adapter.name}`
74
+ const providerUsage = this.db
75
+ .select()
76
+ .from(usageCounters)
77
+ .where(eq(usageCounters.scope, providerScope))
78
+ .all()
79
+ .filter(r => r.period === todayPeriod && r.metric === 'queries')
80
+ .reduce((sum, r) => sum + r.count, 0)
81
+ const limit = p.config.quotaPolicy.maxRequestsPerDay
82
+ if (providerUsage + queriesPerProvider > limit) {
83
+ throw new Error(
84
+ `Daily quota exceeded for ${p.adapter.name}: ${providerUsage} queries used today, ` +
85
+ `limit is ${limit}. This run needs ${queriesPerProvider} more.`,
86
+ )
87
+ }
88
+ }
89
+
90
+ // Per-provider rate limiting: separate sliding windows
91
+ const minuteWindows = new Map<ProviderName, number[]>()
92
+ for (const p of activeProviders) {
93
+ minuteWindows.set(p.adapter.name, [])
94
+ }
95
+
96
+ // Track per-provider errors for partial completion
97
+ const providerErrors = new Map<ProviderName, string>()
98
+ let totalSnapshotsInserted = 0
99
+
100
+ // Process each keyword across all providers
101
+ for (const kw of projectKeywords) {
102
+ // Fan out across providers for this keyword
103
+ const providerPromises = activeProviders.map(async (registeredProvider) => {
104
+ const { adapter, config } = registeredProvider
105
+ const providerName = adapter.name
106
+
107
+ try {
108
+ // Enforce per-minute rate limit
109
+ await this.waitForRateLimit(
110
+ minuteWindows.get(providerName)!,
111
+ config.quotaPolicy.maxRequestsPerMinute,
112
+ )
113
+
114
+ const raw = await adapter.executeTrackedQuery(
115
+ {
116
+ keyword: kw.keyword,
117
+ canonicalDomains: [project.canonicalDomain],
118
+ competitorDomains,
119
+ },
120
+ config,
121
+ )
122
+
123
+ const normalized = adapter.normalizeResult(raw)
124
+
125
+ console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map(s => s.uri))}, canonical="${project.canonicalDomain}"`)
126
+ const citationState = determineCitationState(normalized, project.canonicalDomain)
127
+ const overlap = computeCompetitorOverlap(normalized, competitorDomains)
128
+
129
+ this.db.insert(querySnapshots).values({
130
+ id: crypto.randomUUID(),
131
+ runId,
132
+ keywordId: kw.id,
133
+ provider: providerName,
134
+ citationState,
135
+ answerText: normalized.answerText,
136
+ citedDomains: JSON.stringify(normalized.citedDomains),
137
+ competitorOverlap: JSON.stringify(overlap),
138
+ rawResponse: JSON.stringify({
139
+ model: raw.model,
140
+ groundingSources: normalized.groundingSources,
141
+ searchQueries: normalized.searchQueries,
142
+ apiResponse: raw.rawResponse,
143
+ }),
144
+ createdAt: new Date().toISOString(),
145
+ }).run()
146
+
147
+ totalSnapshotsInserted++
148
+ console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" → ${citationState}`)
149
+ } catch (err: unknown) {
150
+ const msg = err instanceof Error ? err.message : String(err)
151
+ console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`)
152
+ providerErrors.set(providerName, msg)
153
+ }
154
+ })
155
+
156
+ await Promise.all(providerPromises)
157
+ }
158
+
159
+ // Determine final run status
160
+ const allFailed = totalSnapshotsInserted === 0 && providerErrors.size > 0
161
+ const someFailed = providerErrors.size > 0
162
+
163
+ if (allFailed) {
164
+ const errorDetail = JSON.stringify(Object.fromEntries(providerErrors))
165
+ this.db
166
+ .update(runs)
167
+ .set({ status: 'failed', finishedAt: new Date().toISOString(), error: errorDetail })
168
+ .where(eq(runs.id, runId))
169
+ .run()
170
+ } else if (someFailed) {
171
+ const errorDetail = JSON.stringify(Object.fromEntries(providerErrors))
172
+ this.db
173
+ .update(runs)
174
+ .set({ status: 'partial', finishedAt: new Date().toISOString(), error: errorDetail })
175
+ .where(eq(runs.id, runId))
176
+ .run()
177
+ } else {
178
+ this.db
179
+ .update(runs)
180
+ .set({ status: 'completed', finishedAt: new Date().toISOString() })
181
+ .where(eq(runs.id, runId))
182
+ .run()
183
+ }
184
+
185
+ // Increment per-provider usage counters to keep quota checks accurate
186
+ for (const p of activeProviders) {
187
+ this.incrementUsage(`${projectId}:${p.adapter.name}`, 'queries', queriesPerProvider)
188
+ }
189
+ this.incrementUsage(projectId, 'runs', 1)
190
+
191
+ // Notify after run completion
192
+ if (this.onRunCompleted) {
193
+ this.onRunCompleted(runId, projectId).catch((err: unknown) => {
194
+ console.error('[JobRunner] Notification callback failed:', err)
195
+ })
196
+ }
197
+ } catch (err: unknown) {
198
+ // Mark run as failed
199
+ const errorMessage = err instanceof Error ? err.message : String(err)
200
+ this.db
201
+ .update(runs)
202
+ .set({
203
+ status: 'failed',
204
+ finishedAt: new Date().toISOString(),
205
+ error: errorMessage,
206
+ })
207
+ .where(eq(runs.id, runId))
208
+ .run()
209
+
210
+ // Notify on failure too
211
+ if (this.onRunCompleted) {
212
+ this.onRunCompleted(runId, projectId).catch((notifErr: unknown) => {
213
+ console.error('[JobRunner] Notification callback failed:', notifErr)
214
+ })
215
+ }
216
+ }
217
+ }
218
+
219
+ private async waitForRateLimit(window: number[], maxPerMinute: number): Promise<void> {
220
+ const now = Date.now()
221
+ const windowStart = now - 60_000
222
+ while (window.length > 0 && window[0]! < windowStart) {
223
+ window.shift()
224
+ }
225
+ if (window.length >= maxPerMinute) {
226
+ const oldestInWindow = window[0]!
227
+ const waitMs = oldestInWindow + 60_000 - now + 50
228
+ await new Promise(resolve => setTimeout(resolve, waitMs))
229
+ const nowAfterWait = Date.now()
230
+ const newWindowStart = nowAfterWait - 60_000
231
+ while (window.length > 0 && window[0]! < newWindowStart) {
232
+ window.shift()
233
+ }
234
+ }
235
+ window.push(Date.now())
236
+ }
237
+
238
+ private incrementUsage(scope: string, metric: string, count: number): void {
239
+ const now = new Date()
240
+ const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`
241
+ const id = crypto.randomUUID()
242
+
243
+ const existing = this.db
244
+ .select()
245
+ .from(usageCounters)
246
+ .where(eq(usageCounters.scope, scope))
247
+ .all()
248
+ .find(r => r.period === period && r.metric === metric)
249
+
250
+ if (existing) {
251
+ this.db
252
+ .update(usageCounters)
253
+ .set({ count: existing.count + count, updatedAt: now.toISOString() })
254
+ .where(eq(usageCounters.id, existing.id))
255
+ .run()
256
+ } else {
257
+ this.db.insert(usageCounters).values({
258
+ id,
259
+ scope,
260
+ period,
261
+ metric,
262
+ count,
263
+ updatedAt: now.toISOString(),
264
+ }).run()
265
+ }
266
+ }
267
+ }
268
+
269
+ function getCurrentPeriod(): string {
270
+ const d = new Date()
271
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`
272
+ }
273
+
274
+ /** Normalize a canonical domain that may be stored as a full URL (e.g. "https://www.ainyc.ai") to a bare domain ("ainyc.ai") */
275
+ function normalizeDomain(input: string): string {
276
+ let domain = input
277
+ try {
278
+ if (domain.includes('://')) {
279
+ domain = new URL(domain).hostname
280
+ }
281
+ } catch {
282
+ // not a URL, use as-is
283
+ }
284
+ return domain.replace(/^www\./, '')
285
+ }
286
+
287
+ function domainMatches(domain: string, canonicalDomain: string): boolean {
288
+ const normalized = normalizeDomain(canonicalDomain)
289
+ const d = normalizeDomain(domain)
290
+ return d === normalized || d.endsWith(`.${normalized}`)
291
+ }
292
+
293
+ function determineCitationState(
294
+ normalized: NormalizedQueryResult,
295
+ canonicalDomain: string,
296
+ ): 'cited' | 'not-cited' {
297
+ const bareDomain = normalizeDomain(canonicalDomain)
298
+
299
+ // Check extracted cited domains
300
+ if (normalized.citedDomains.some(d => domainMatches(d, bareDomain))) {
301
+ return 'cited'
302
+ }
303
+
304
+ // Also check grounding source URIs and titles directly
305
+ const lowerDomain = bareDomain.toLowerCase()
306
+ for (const source of normalized.groundingSources) {
307
+ try {
308
+ const uri = source.uri.toLowerCase()
309
+ if (uri.includes(lowerDomain)) {
310
+ return 'cited'
311
+ }
312
+ } catch {
313
+ // ignore
314
+ }
315
+ // Gemini proxy URLs use base64 encoding, so the domain won't appear in the URI.
316
+ // The title field often contains the bare domain (e.g. "ainyc.ai").
317
+ if (source.title) {
318
+ const titleLower = source.title.toLowerCase().replace(/^www\./, '')
319
+ if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
320
+ return 'cited'
321
+ }
322
+ }
323
+ }
324
+
325
+ return 'not-cited'
326
+ }
327
+
328
+ function computeCompetitorOverlap(
329
+ normalized: NormalizedQueryResult,
330
+ competitorDomains: string[],
331
+ ): string[] {
332
+ const overlapSet = new Set<string>()
333
+
334
+ // Check extracted cited domains
335
+ for (const d of normalized.citedDomains) {
336
+ for (const cd of competitorDomains) {
337
+ if (domainMatches(d, cd)) {
338
+ overlapSet.add(cd)
339
+ }
340
+ }
341
+ }
342
+
343
+ // Check grounding source URIs (handles proxy URLs)
344
+ for (const source of normalized.groundingSources) {
345
+ const uri = source.uri.toLowerCase()
346
+ for (const cd of competitorDomains) {
347
+ if (uri.includes(cd.toLowerCase())) {
348
+ overlapSet.add(cd)
349
+ }
350
+ }
351
+ }
352
+
353
+ // Check answer text for competitor domain mentions
354
+ if (normalized.answerText) {
355
+ const lowerAnswer = normalized.answerText.toLowerCase()
356
+ for (const cd of competitorDomains) {
357
+ if (lowerAnswer.includes(cd.toLowerCase())) {
358
+ overlapSet.add(cd)
359
+ }
360
+ const brand = cd.split('.')[0]
361
+ if (brand && brand.length >= 4 && new RegExp(`\\b${brand}\\b`, 'i').test(lowerAnswer)) {
362
+ overlapSet.add(cd)
363
+ }
364
+ }
365
+ }
366
+
367
+ return [...overlapSet]
368
+ }