diff --git a/README.md b/README.md
index 2c486e761..12542d60d 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,15 @@ Stuck? The fastest path is usually:
## Star History
-[](https://star-history.com/#TraderAlice/OpenAlice&Date)
+
+
+
+
+
+
+
+
+
## Contributors
diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg
new file mode 100644
index 000000000..7b5f52d75
--- /dev/null
+++ b/docs/images/star-history.svg
@@ -0,0 +1,83 @@
+
+ TraderAlice/OpenAlice star history
+ 6,283 stars in this snapshot. 669 stars added in the latest 30-day window. Daily cumulative history from Feb 19, 2026 through Jul 28, 2026.
+
+ TraderAlice/OpenAlice
+ 2026-07-28T06:56:51.503Z
+ daily cumulative active stargazers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OPENALICE
+ Star History
+
+
+ 6,283
+ STARS AT SNAPSHOT
+
+ +669
+ LAST 30 DAYS
+
+
+ DAILY CUMULATIVE · THROUGH JUL 28, 2026 UTC
+
+
+ 0
+
+ 2k
+
+ 4k
+
+ 6k
+
+ 8k
+
+
+ Feb 19
+
+ Mar
+
+ Apr
+
+ May
+
+ Jun
+
+ Jul
+
+ Jul 28
+
+
+
+
diff --git a/package.json b/package.json
index 5ef532aec..6f67bffb3 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"test:guardian-recovery": "tsx scripts/guardian/runtime-recovery-smoke.ts",
"test:connector-service": "node scripts/connector-service-smoke.mjs",
"test:connector-replay": "vitest run services/connector/src/core/io-replay.spec.ts services/connector/src/core/io-events.spec.ts services/connector/src/adapters/shared.spec.ts",
+ "star-history:generate": "node scripts/generate-star-history.mjs",
"postinstall": "node scripts/fix-pty-perms.mjs"
},
"keywords": [
diff --git a/scripts/generate-star-history.mjs b/scripts/generate-star-history.mjs
new file mode 100644
index 000000000..408088885
--- /dev/null
+++ b/scripts/generate-star-history.mjs
@@ -0,0 +1,339 @@
+#!/usr/bin/env node
+
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const DEFAULT_REPOSITORY = 'TraderAlice/OpenAlice'
+const DEFAULT_OUTPUT = 'docs/images/star-history.svg'
+
+function startOfUtcDay(value) {
+ const date = new Date(value)
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
+}
+
+function formatCount(value) {
+ return new Intl.NumberFormat('en-US').format(value)
+}
+
+function formatCompactCount(value) {
+ if (value < 1_000) return String(value)
+ const thousands = value / 1_000
+ return `${thousands >= 10 || Number.isInteger(thousands) ? thousands.toFixed(0) : thousands.toFixed(1)}k`
+}
+
+function formatDate(value, includeDay = true) {
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ ...(includeDay ? { day: 'numeric' } : {}),
+ timeZone: 'UTC',
+ }).format(value)
+}
+
+function formatFullDate(value) {
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ timeZone: 'UTC',
+ }).format(value)
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function niceAxis(maximum, targetIntervals = 4) {
+ if (maximum <= 0) return { maximum: 1, step: 1 }
+ const roughStep = maximum / targetIntervals
+ const magnitude = 10 ** Math.floor(Math.log10(roughStep))
+ const normalized = roughStep / magnitude
+ const niceNormalized = normalized <= 1
+ ? 1
+ : normalized <= 2
+ ? 2
+ : normalized <= 2.5
+ ? 2.5
+ : normalized <= 5
+ ? 5
+ : 10
+ const step = niceNormalized * magnitude
+ return {
+ maximum: Math.ceil(maximum / step) * step,
+ step,
+ }
+}
+
+export function aggregateStarHistory(timestamps) {
+ const sorted = timestamps
+ .map((value) => new Date(value))
+ .filter((value) => !Number.isNaN(value.getTime()))
+ .sort((left, right) => left.getTime() - right.getTime())
+
+ if (sorted.length === 0) {
+ throw new Error('[star-history] GitHub returned no valid stargazer timestamps')
+ }
+
+ const firstDay = startOfUtcDay(sorted[0])
+ const lastDay = startOfUtcDay(sorted.at(-1))
+ const dayCount = Math.floor((lastDay - firstDay) / DAY_MS) + 1
+ const points = []
+ let starIndex = 0
+
+ for (let dayIndex = 0; dayIndex < dayCount; dayIndex += 1) {
+ const day = firstDay + dayIndex * DAY_MS
+ const nextDay = day + DAY_MS
+ while (starIndex < sorted.length && sorted[starIndex].getTime() < nextDay) {
+ starIndex += 1
+ }
+ points.push({ date: new Date(day), count: starIndex })
+ }
+
+ return points
+}
+
+function selectDateTicks(points) {
+ const ticks = [points[0]]
+ for (let index = 1; index < points.length - 1; index += 1) {
+ const date = points[index].date
+ if (date.getUTCDate() === 1) ticks.push(points[index])
+ }
+ if (points.length > 1) ticks.push(points.at(-1))
+ return ticks
+}
+
+export function renderStarHistorySvg({
+ points,
+ repository = DEFAULT_REPOSITORY,
+ generatedAt = new Date(),
+}) {
+ if (!Array.isArray(points) || points.length === 0) {
+ throw new Error('[star-history] at least one aggregate point is required')
+ }
+
+ const width = 1200
+ const height = 560
+ const plot = {
+ left: 92,
+ right: 1144,
+ top: 174,
+ bottom: 478,
+ }
+ const plotWidth = plot.right - plot.left
+ const plotHeight = plot.bottom - plot.top
+ const total = points.at(-1).count
+ const axis = niceAxis(total)
+ const xForIndex = (index) => {
+ if (points.length === 1) return plot.left
+ return plot.left + (index / (points.length - 1)) * plotWidth
+ }
+ const yForCount = (count) => plot.bottom - (count / axis.maximum) * plotHeight
+ const linePath = points
+ .map((point, index) => `${index === 0 ? 'M' : 'L'} ${xForIndex(index).toFixed(2)} ${yForCount(point.count).toFixed(2)}`)
+ .join(' ')
+ const areaPath = `${linePath} L ${xForIndex(points.length - 1).toFixed(2)} ${plot.bottom} L ${plot.left} ${plot.bottom} Z`
+ const intervalCount = Math.round(axis.maximum / axis.step)
+ const yTicks = Array.from({ length: intervalCount + 1 }, (_, index) => index * axis.step)
+ const dateTicks = selectDateTicks(points)
+ const pointIndexByTime = new Map(points.map((point, index) => [point.date.getTime(), index]))
+ const thirtyDayBaselineIndex = Math.max(0, points.length - 31)
+ const lastThirtyDays = total - points[thirtyDayBaselineIndex].count
+ const title = `${repository} star history`
+ const description = `${formatCount(total)} stars in this snapshot. ${formatCount(lastThirtyDays)} stars added in the latest 30-day window. Daily cumulative history from ${formatFullDate(points[0].date)} through ${formatFullDate(points.at(-1).date)}.`
+
+ const grid = yTicks
+ .map((tick) => {
+ const y = yForCount(tick)
+ return `
+
+ ${formatCompactCount(tick)} `
+ })
+ .join('')
+
+ const xAxis = dateTicks
+ .map((point, tickIndex) => {
+ const index = pointIndexByTime.get(point.date.getTime())
+ const x = xForIndex(index)
+ const isEndpoint = tickIndex === 0 || tickIndex === dateTicks.length - 1
+ return `
+
+ ${formatDate(point.date, isEndpoint)} `
+ })
+ .join('')
+
+ return `
+ ${escapeXml(title)}
+ ${escapeXml(description)}
+
+ ${escapeXml(repository)}
+ ${escapeXml(generatedAt.toISOString())}
+ daily cumulative active stargazers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OPENALICE
+ Star History
+
+
+ ${formatCount(total)}
+ STARS AT SNAPSHOT
+
+ +${formatCount(lastThirtyDays)}
+ LAST 30 DAYS
+
+
+ DAILY CUMULATIVE · THROUGH ${formatFullDate(points.at(-1).date).toUpperCase()} UTC
+${grid}
+${xAxis}
+
+
+
+
+`
+}
+
+export async function fetchStargazerTimestamps({
+ repository = DEFAULT_REPOSITORY,
+ token,
+ fetchImpl = fetch,
+ onPage,
+}) {
+ if (!token) throw new Error('[star-history] a GitHub token is required')
+
+ const timestamps = []
+ const perPage = 100
+ for (let page = 1; ; page += 1) {
+ const url = new URL(`https://api.github.com/repos/${repository}/stargazers`)
+ url.searchParams.set('per_page', String(perPage))
+ url.searchParams.set('page', String(page))
+ const response = await fetchImpl(url, {
+ headers: {
+ Accept: 'application/vnd.github.star+json',
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': 'OpenAlice-Star-History',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(`[star-history] GitHub API returned ${response.status}: ${detail.slice(0, 240)}`)
+ }
+
+ const pageItems = await response.json()
+ if (!Array.isArray(pageItems)) {
+ throw new Error('[star-history] GitHub API returned an unexpected response')
+ }
+
+ for (const item of pageItems) {
+ if (typeof item?.starred_at === 'string') timestamps.push(item.starred_at)
+ }
+ onPage?.({ page, fetched: pageItems.length, total: timestamps.length })
+
+ if (pageItems.length < perPage) break
+ }
+
+ return timestamps
+}
+
+function resolveGitHubToken() {
+ const environmentToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
+ if (environmentToken) return environmentToken
+
+ try {
+ return execFileSync('gh', ['auth', 'token'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim()
+ } catch {
+ throw new Error('[star-history] set GH_TOKEN/GITHUB_TOKEN or authenticate with `gh auth login`')
+ }
+}
+
+function parseArgs(argv) {
+ const values = {
+ repository: DEFAULT_REPOSITORY,
+ output: DEFAULT_OUTPUT,
+ }
+
+ for (let index = 0; index < argv.length; index += 2) {
+ const key = argv[index]
+ const value = argv[index + 1]
+ if (!key?.startsWith('--') || value == null) {
+ throw new Error(`[star-history] invalid arguments: ${argv.join(' ')}`)
+ }
+ if (key === '--repository') values.repository = value
+ else if (key === '--output') values.output = value
+ else throw new Error(`[star-history] unknown option: ${key}`)
+ }
+
+ return values
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2))
+ const token = resolveGitHubToken()
+ const timestamps = await fetchStargazerTimestamps({
+ repository: options.repository,
+ token,
+ onPage: ({ page, total }) => {
+ if (page === 1 || page % 10 === 0) {
+ console.log(`[star-history] fetched page ${page} (${formatCount(total)} timestamps)`)
+ }
+ },
+ })
+ const points = aggregateStarHistory(timestamps)
+ const svg = renderStarHistorySvg({
+ points,
+ repository: options.repository,
+ })
+ const outputPath = resolve(options.output)
+ mkdirSync(dirname(outputPath), { recursive: true })
+ writeFileSync(outputPath, svg)
+ console.log(`[star-history] wrote ${options.output} from ${formatCount(timestamps.length)} active stargazers`)
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error)
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/generate-star-history.spec.ts b/scripts/generate-star-history.spec.ts
new file mode 100644
index 000000000..a2f3cea2b
--- /dev/null
+++ b/scripts/generate-star-history.spec.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ aggregateStarHistory,
+ fetchStargazerTimestamps,
+ renderStarHistorySvg,
+} from './generate-star-history.mjs'
+
+describe('aggregateStarHistory', () => {
+ it('creates a cumulative point for every UTC day without retaining user data', () => {
+ const points = aggregateStarHistory([
+ '2026-02-21T18:00:00Z',
+ '2026-02-19T10:40:01Z',
+ '2026-02-19T20:15:00Z',
+ ])
+
+ expect(points).toEqual([
+ { date: new Date('2026-02-19T00:00:00Z'), count: 2 },
+ { date: new Date('2026-02-20T00:00:00Z'), count: 2 },
+ { date: new Date('2026-02-21T00:00:00Z'), count: 3 },
+ ])
+ })
+
+ it('rejects empty or invalid histories', () => {
+ expect(() => aggregateStarHistory([])).toThrow('no valid stargazer timestamps')
+ expect(() => aggregateStarHistory(['not-a-date'])).toThrow('no valid stargazer timestamps')
+ })
+})
+
+describe('fetchStargazerTimestamps', () => {
+ it('paginates and keeps only timestamp fields', async () => {
+ const firstPage = Array.from({ length: 100 }, (_, index) => ({
+ starred_at: `2026-02-19T10:${String(index % 60).padStart(2, '0')}:00Z`,
+ user: { login: `private-user-${index}` },
+ }))
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => firstPage,
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ starred_at: '2026-02-20T12:00:00Z', user: { login: 'last-user' } }],
+ })
+
+ const timestamps = await fetchStargazerTimestamps({
+ token: 'test-token',
+ fetchImpl,
+ })
+
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
+ expect(timestamps).toHaveLength(101)
+ expect(timestamps.at(-1)).toBe('2026-02-20T12:00:00Z')
+ expect(JSON.stringify(timestamps)).not.toContain('private-user')
+ })
+})
+
+describe('renderStarHistorySvg', () => {
+ it('renders an accessible self-contained chart without stargazer identities', () => {
+ const points = aggregateStarHistory([
+ '2026-02-19T10:40:01Z',
+ '2026-02-20T12:00:00Z',
+ '2026-03-20T12:00:00Z',
+ ])
+ const svg = renderStarHistorySvg({
+ points,
+ generatedAt: new Date('2026-03-20T13:00:00Z'),
+ })
+
+ expect(svg).toContain('TraderAlice/OpenAlice star history ')
+ expect(svg).toContain('')
+ expect(svg).toContain('daily cumulative active stargazers ')
+ expect(svg).toContain('2026-03-20T13:00:00.000Z ')
+ expect(svg).not.toContain('login')
+ expect(svg).not.toContain('starred_at')
+ })
+})