@shigureni/minion 0.1.0 → 0.3.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/README.md +79 -33
- package/cli/src/app.ts +2 -0
- package/cli/src/factory.ts +8 -1
- package/cli/src/index.ts +15 -2
- package/cli/src/lib/body-validator.ts +13 -0
- package/cli/src/lib/format.ts +36 -0
- package/cli/src/router.ts +2 -1
- package/cli/src/routes/config/get/[key]/route.ts +7 -7
- package/cli/src/routes/config/list/route.ts +4 -5
- package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
- package/cli/src/routes/dev/route.ts +12 -7
- package/cli/src/routes/dex/route.ts +66 -0
- package/cli/src/routes/kill/route.ts +7 -5
- package/cli/src/routes/not-found.ts +1 -1
- package/cli/src/routes/reboot/route.ts +12 -7
- package/cli/src/routes/start/route.ts +7 -6
- package/cli/src/routes/status/route.ts +5 -5
- package/lib/engine/app/app-paths.ts +46 -0
- package/lib/engine/app/app-runner.ts +235 -0
- package/lib/engine/app/source-hash.ts +34 -0
- package/lib/engine/collection/achievements.ts +143 -0
- package/lib/engine/collection/collection-store.ts +43 -0
- package/lib/engine/collection/collection-tracker.ts +97 -0
- package/lib/engine/collection/moon.ts +26 -0
- package/lib/engine/collection/species.ts +275 -0
- package/lib/engine/config/config-store.ts +36 -0
- package/lib/engine/errors.ts +20 -0
- package/lib/engine/fs/file-system.ts +31 -0
- package/lib/engine/fs/json-file-store.ts +57 -0
- package/lib/engine/fs/memory-file-system.ts +106 -0
- package/lib/engine/fs/node-file-system.ts +87 -0
- package/lib/engine/gateway/gateway-daemon.ts +14 -0
- package/lib/engine/gateway/gateway-routes.ts +17 -0
- package/lib/engine/gateway/gateway-server.ts +126 -0
- package/lib/engine/gateway/pet-behavior.ts +164 -0
- package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
- package/lib/engine/gateway/sessions.ts +127 -0
- package/lib/engine/process/memory-process-runner.ts +67 -0
- package/lib/engine/process/node-process-runner.ts +51 -0
- package/lib/engine/process/process-runner.ts +21 -0
- package/lib/engine/random/memory-random-source.ts +22 -0
- package/lib/engine/random/node-random-source.ts +7 -0
- package/lib/engine/random/random-source.ts +9 -0
- package/lib/engine/stats/date-window.ts +31 -0
- package/lib/engine/stats/session-stats-tracker.ts +125 -0
- package/lib/engine/stats/stats-collector.ts +100 -0
- package/lib/engine/stats/stats-snapshot.ts +31 -0
- package/lib/engine/stats/token-usage-tracker.ts +170 -0
- package/lib/engine/time/clock.ts +11 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/index.ts +65 -0
- package/lib/minion.ts +194 -0
- package/package.json +9 -1
- package/swift/Sources/open-minion/open_minion.swift +5 -1
- package/tsconfig.json +3 -2
- package/cli/src/gateway.ts +0 -204
- package/cli/src/lib/process.ts +0 -204
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import type { MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
3
|
+
import { JsonFileStore } from "@lib/engine/fs/json-file-store"
|
|
4
|
+
import type { SessionInfo } from "@lib/engine/gateway/sessions"
|
|
5
|
+
import { isNextDay } from "@lib/engine/stats/date-window"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-field `.catch()` defaults double as the schema-evolution backfill: a
|
|
9
|
+
* file written by an older version of this tracker (missing a field, e.g.
|
|
10
|
+
* `seenProjects` was added later) reads back with that field defaulted
|
|
11
|
+
* instead of the whole file being discarded.
|
|
12
|
+
*/
|
|
13
|
+
const schema = z.object({
|
|
14
|
+
totalSessionsSeen: z.number().catch(0),
|
|
15
|
+
maxConcurrentSessions: z.number().catch(0),
|
|
16
|
+
/** Bounded, most-recent-first log of distinct session ids, used only to dedup lifetime counting across restarts. */
|
|
17
|
+
seenSessionIds: z.array(z.string()).catch([]),
|
|
18
|
+
/** Bounded log of distinct working directories seen, used only to dedup `uniqueProjectsSeen` across restarts. */
|
|
19
|
+
seenProjects: z.array(z.string()).catch([]),
|
|
20
|
+
currentStreakDays: z.number().catch(0),
|
|
21
|
+
longestStreakDays: z.number().catch(0),
|
|
22
|
+
/** "YYYY-MM-DD" (UTC) of the last `record()` call that saw at least one active session. */
|
|
23
|
+
lastActiveDate: z.string().nullable().catch(null),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
type SessionStatsData = z.infer<typeof schema>
|
|
27
|
+
|
|
28
|
+
const EMPTY: SessionStatsData = {
|
|
29
|
+
totalSessionsSeen: 0,
|
|
30
|
+
maxConcurrentSessions: 0,
|
|
31
|
+
seenSessionIds: [],
|
|
32
|
+
seenProjects: [],
|
|
33
|
+
currentStreakDays: 0,
|
|
34
|
+
longestStreakDays: 0,
|
|
35
|
+
lastActiveDate: null,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Bounds the dedup logs' size; an id/project aged out here can be recounted
|
|
39
|
+
// as "new" if it somehow reappears, which only over-counts figures that are
|
|
40
|
+
// already just fun numbers, not something that needs to be exact.
|
|
41
|
+
const MAX_SEEN_IDS = 5000
|
|
42
|
+
const MAX_SEEN_PROJECTS = 2000
|
|
43
|
+
|
|
44
|
+
export type SessionStatsSummary = {
|
|
45
|
+
totalSessionsSeen: number
|
|
46
|
+
currentConcurrentSessions: number
|
|
47
|
+
maxConcurrentSessions: number
|
|
48
|
+
uniqueProjectsSeen: number
|
|
49
|
+
currentStreakDays: number
|
|
50
|
+
longestStreakDays: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tracks lifetime distinct session/project counts, the concurrency
|
|
55
|
+
* high-water-mark, and a day-streak of "was any session active that day",
|
|
56
|
+
* all persisted across gateway restarts.
|
|
57
|
+
*/
|
|
58
|
+
export class SessionStatsTracker {
|
|
59
|
+
private readonly store: JsonFileStore<SessionStatsData>
|
|
60
|
+
|
|
61
|
+
constructor(props: { fs: MinionFileSystem; path: string }) {
|
|
62
|
+
this.store = new JsonFileStore({ fs: props.fs, path: props.path, schema, defaultValue: EMPTY })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Records the latest active-session snapshot (at `now`) and returns the updated summary, or the write failure. */
|
|
66
|
+
record(activeSessions: Map<string, SessionInfo>, now: Date): SessionStatsSummary | Error {
|
|
67
|
+
const data = this.store.read()
|
|
68
|
+
const seenIds = new Set(data.seenSessionIds)
|
|
69
|
+
const seenProjects = new Set(data.seenProjects)
|
|
70
|
+
|
|
71
|
+
for (const info of activeSessions.values()) {
|
|
72
|
+
if (info.cwd) seenProjects.add(info.cwd)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const id of activeSessions.keys()) {
|
|
76
|
+
if (seenIds.has(id)) continue
|
|
77
|
+
seenIds.add(id)
|
|
78
|
+
data.totalSessionsSeen += 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
data.seenSessionIds = Array.from(seenIds).slice(-MAX_SEEN_IDS)
|
|
82
|
+
data.seenProjects = Array.from(seenProjects).slice(-MAX_SEEN_PROJECTS)
|
|
83
|
+
data.maxConcurrentSessions = Math.max(data.maxConcurrentSessions, activeSessions.size)
|
|
84
|
+
|
|
85
|
+
if (activeSessions.size > 0) {
|
|
86
|
+
this.recordActiveDay(data, now.toISOString().slice(0, 10))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const writeError = this.store.write(data)
|
|
90
|
+
if (writeError) return writeError
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
totalSessionsSeen: data.totalSessionsSeen,
|
|
94
|
+
currentConcurrentSessions: activeSessions.size,
|
|
95
|
+
maxConcurrentSessions: data.maxConcurrentSessions,
|
|
96
|
+
uniqueProjectsSeen: data.seenProjects.length,
|
|
97
|
+
currentStreakDays: data.currentStreakDays,
|
|
98
|
+
longestStreakDays: data.longestStreakDays,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Persisted totals without recording a new sample; `currentConcurrentSessions` is always 0 here since no live sessions were read. */
|
|
103
|
+
summary(): SessionStatsSummary {
|
|
104
|
+
const data = this.store.read()
|
|
105
|
+
return {
|
|
106
|
+
totalSessionsSeen: data.totalSessionsSeen,
|
|
107
|
+
currentConcurrentSessions: 0,
|
|
108
|
+
maxConcurrentSessions: data.maxConcurrentSessions,
|
|
109
|
+
uniqueProjectsSeen: data.seenProjects.length,
|
|
110
|
+
currentStreakDays: data.currentStreakDays,
|
|
111
|
+
longestStreakDays: data.longestStreakDays,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private recordActiveDay(data: SessionStatsData, today: string): void {
|
|
116
|
+
if (data.lastActiveDate === today) return // already recorded today
|
|
117
|
+
|
|
118
|
+
data.currentStreakDays =
|
|
119
|
+
data.lastActiveDate !== null && isNextDay(data.lastActiveDate, today)
|
|
120
|
+
? data.currentStreakDays + 1
|
|
121
|
+
: 1
|
|
122
|
+
data.longestStreakDays = Math.max(data.longestStreakDays, data.currentStreakDays)
|
|
123
|
+
data.lastActiveDate = today
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
2
|
+
import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
|
|
3
|
+
import type { MinionClock } from "@lib/engine/time/clock"
|
|
4
|
+
import { readActiveSessions } from "@lib/engine/gateway/sessions"
|
|
5
|
+
import type { SessionStatsSummary } from "@lib/engine/stats/session-stats-tracker"
|
|
6
|
+
import { SessionStatsTracker } from "@lib/engine/stats/session-stats-tracker"
|
|
7
|
+
import type { TokenUsageSummary } from "@lib/engine/stats/token-usage-tracker"
|
|
8
|
+
import { TokenUsageTracker } from "@lib/engine/stats/token-usage-tracker"
|
|
9
|
+
import { type StatsSnapshot, timeBucketForHour } from "@lib/engine/stats/stats-snapshot"
|
|
10
|
+
import { sumRecentDays } from "@lib/engine/stats/date-window"
|
|
11
|
+
|
|
12
|
+
const WEEK_DAYS = 7
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
fs: MinionFileSystem
|
|
16
|
+
process: MinionProcessRunner
|
|
17
|
+
clock: MinionClock
|
|
18
|
+
sessionsDir: string
|
|
19
|
+
projectsDir: string
|
|
20
|
+
sessionStats: SessionStatsTracker
|
|
21
|
+
tokenUsage: TokenUsageTracker
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Combines session tracking and token-usage scanning into the single
|
|
26
|
+
* `StatsSnapshot` that achievement and minion-species conditions evaluate
|
|
27
|
+
* against.
|
|
28
|
+
*/
|
|
29
|
+
export class MinionStatsCollector {
|
|
30
|
+
private readonly fs: MinionFileSystem
|
|
31
|
+
private readonly process: MinionProcessRunner
|
|
32
|
+
private readonly clock: MinionClock
|
|
33
|
+
private readonly sessionsDir: string
|
|
34
|
+
private readonly projectsDir: string
|
|
35
|
+
private readonly sessionStats: SessionStatsTracker
|
|
36
|
+
private readonly tokenUsage: TokenUsageTracker
|
|
37
|
+
|
|
38
|
+
constructor(props: Props) {
|
|
39
|
+
this.fs = props.fs
|
|
40
|
+
this.process = props.process
|
|
41
|
+
this.clock = props.clock
|
|
42
|
+
this.sessionsDir = props.sessionsDir
|
|
43
|
+
this.projectsDir = props.projectsDir
|
|
44
|
+
this.sessionStats = props.sessionStats
|
|
45
|
+
this.tokenUsage = props.tokenUsage
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reads the current sessions and re-scans token usage, recording both.
|
|
50
|
+
* Touches disk (transcripts can be large) — call this on a slow cadence,
|
|
51
|
+
* not on every animation tick. Returns an Error when persisting either
|
|
52
|
+
* tracker's state fails.
|
|
53
|
+
*/
|
|
54
|
+
collect(): StatsSnapshot | Error {
|
|
55
|
+
const activeSessions = readActiveSessions({
|
|
56
|
+
fs: this.fs,
|
|
57
|
+
process: this.process,
|
|
58
|
+
clock: this.clock,
|
|
59
|
+
sessionsDir: this.sessionsDir,
|
|
60
|
+
projectsDir: this.projectsDir,
|
|
61
|
+
})
|
|
62
|
+
const sessionSummary = this.sessionStats.record(activeSessions, this.clock.now())
|
|
63
|
+
if (sessionSummary instanceof Error) return sessionSummary
|
|
64
|
+
const usage = this.tokenUsage.scan()
|
|
65
|
+
if (usage instanceof Error) return usage
|
|
66
|
+
return this.buildSnapshot(sessionSummary, usage)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cheap read of the last persisted totals, without touching sessions or transcripts. */
|
|
70
|
+
peek(): StatsSnapshot {
|
|
71
|
+
const sessionSummary = this.sessionStats.summary()
|
|
72
|
+
const usage = this.tokenUsage.summary()
|
|
73
|
+
return this.buildSnapshot(sessionSummary, usage)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private buildSnapshot(
|
|
77
|
+
sessionSummary: SessionStatsSummary,
|
|
78
|
+
usage: TokenUsageSummary,
|
|
79
|
+
): StatsSnapshot {
|
|
80
|
+
const now = this.clock.now()
|
|
81
|
+
const hour = now.getHours()
|
|
82
|
+
// UTC date, matching TokenUsageTracker's transcript-timestamp bucketing.
|
|
83
|
+
const today = now.toISOString().slice(0, 10)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
now,
|
|
87
|
+
hour,
|
|
88
|
+
timeBucket: timeBucketForHour(hour),
|
|
89
|
+
currentConcurrentSessions: sessionSummary.currentConcurrentSessions,
|
|
90
|
+
maxConcurrentSessions: sessionSummary.maxConcurrentSessions,
|
|
91
|
+
totalSessionsSeen: sessionSummary.totalSessionsSeen,
|
|
92
|
+
uniqueProjectsSeen: sessionSummary.uniqueProjectsSeen,
|
|
93
|
+
currentStreakDays: sessionSummary.currentStreakDays,
|
|
94
|
+
longestStreakDays: sessionSummary.longestStreakDays,
|
|
95
|
+
tokensTotal: usage.tokensTotal,
|
|
96
|
+
tokensToday: usage.tokensByDate[today] ?? 0,
|
|
97
|
+
tokensThisWeek: sumRecentDays(usage.tokensByDate, today, WEEK_DAYS),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type TimeBucket = "lateNight" | "morning" | "day" | "evening" | "night"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Buckets an hour-of-day (0-23, local time) into one of five time-of-day
|
|
5
|
+
* windows. Covers every hour exactly once, so `resolveSpecies` always has a
|
|
6
|
+
* fallback time-of-day species to match against.
|
|
7
|
+
*/
|
|
8
|
+
export function timeBucketForHour(hour: number): TimeBucket {
|
|
9
|
+
if (hour < 5) return "lateNight" // 0-4
|
|
10
|
+
if (hour < 10) return "morning" // 5-9
|
|
11
|
+
if (hour < 17) return "day" // 10-16
|
|
12
|
+
if (hour < 20) return "evening" // 17-19
|
|
13
|
+
return "night" // 20-23
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StatsSnapshot = {
|
|
17
|
+
now: Date
|
|
18
|
+
hour: number
|
|
19
|
+
timeBucket: TimeBucket
|
|
20
|
+
currentConcurrentSessions: number
|
|
21
|
+
maxConcurrentSessions: number
|
|
22
|
+
totalSessionsSeen: number
|
|
23
|
+
uniqueProjectsSeen: number
|
|
24
|
+
currentStreakDays: number
|
|
25
|
+
longestStreakDays: number
|
|
26
|
+
tokensTotal: number
|
|
27
|
+
/** Tokens consumed on `now`'s (UTC) calendar date. */
|
|
28
|
+
tokensToday: number
|
|
29
|
+
/** Rolling 7-day sum ending on `now`'s (UTC) calendar date. */
|
|
30
|
+
tokensThisWeek: number
|
|
31
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { safeJsonParse } from "@lib/engine/errors"
|
|
4
|
+
import type { MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
5
|
+
import { JsonFileStore } from "@lib/engine/fs/json-file-store"
|
|
6
|
+
import type { MinionClock } from "@lib/engine/time/clock"
|
|
7
|
+
|
|
8
|
+
const scanDataSchema = z.object({
|
|
9
|
+
tokensTotal: z.number().catch(0),
|
|
10
|
+
tokensByDate: z.record(z.string(), z.number()).catch({}),
|
|
11
|
+
files: z
|
|
12
|
+
.record(z.string(), z.object({ mtimeMs: z.number(), linesConsumed: z.number() }))
|
|
13
|
+
.catch({}),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
type UsageScanData = z.infer<typeof scanDataSchema>
|
|
17
|
+
|
|
18
|
+
const EMPTY: UsageScanData = { tokensTotal: 0, tokensByDate: {}, files: {} }
|
|
19
|
+
|
|
20
|
+
// Bounds tokensByDate's growth; only the running total needs to survive
|
|
21
|
+
// forever, per-day figures beyond ~4 months aren't queried by anything here.
|
|
22
|
+
const MAX_TRACKED_DATES = 120
|
|
23
|
+
|
|
24
|
+
// Backfill cutoff: an unseen transcript whose mtime is older than this is
|
|
25
|
+
// skipped without being read. The rolling figures (today / this week) only
|
|
26
|
+
// need the trailing 7 days, so the first-ever scan parses the active week
|
|
27
|
+
// instead of the full history — lifetime totals just accrue from that point
|
|
28
|
+
// on. Must stay >= the WEEK_DAYS window in stats-collector.ts. A dormant
|
|
29
|
+
// file that gets appended later turns recent again and is then counted whole,
|
|
30
|
+
// history included — a harmless one-time bump.
|
|
31
|
+
const BACKFILL_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
export type TokenUsageSummary = {
|
|
34
|
+
tokensTotal: number
|
|
35
|
+
tokensByDate: Record<string, number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Props = {
|
|
39
|
+
fs: MinionFileSystem
|
|
40
|
+
clock: MinionClock
|
|
41
|
+
path: string
|
|
42
|
+
/** Claude Code's transcript root, e.g. `~/.claude/projects`. */
|
|
43
|
+
projectsDir: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Incrementally sums token usage out of Claude Code's transcript files
|
|
48
|
+
* (`<projectsDir>/**\/*.jsonl`, one JSON object per line). Tracks each
|
|
49
|
+
* file's mtime and how many lines were already counted, so a scan only
|
|
50
|
+
* re-reads files that changed since the last scan, and only parses newly
|
|
51
|
+
* appended lines within those — this keeps repeated scans cheap even though
|
|
52
|
+
* the underlying transcripts only grow over time.
|
|
53
|
+
*/
|
|
54
|
+
export class TokenUsageTracker {
|
|
55
|
+
private readonly fs: MinionFileSystem
|
|
56
|
+
private readonly clock: MinionClock
|
|
57
|
+
private readonly projectsDir: string
|
|
58
|
+
private readonly store: JsonFileStore<UsageScanData>
|
|
59
|
+
|
|
60
|
+
constructor(props: Props) {
|
|
61
|
+
this.fs = props.fs
|
|
62
|
+
this.clock = props.clock
|
|
63
|
+
this.projectsDir = props.projectsDir
|
|
64
|
+
this.store = new JsonFileStore({
|
|
65
|
+
fs: props.fs,
|
|
66
|
+
path: props.path,
|
|
67
|
+
schema: scanDataSchema,
|
|
68
|
+
defaultValue: EMPTY,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Re-scans changed transcript files and persists the updated totals.
|
|
74
|
+
* Touches disk — call on a slow cadence. An unreadable transcript dir/file
|
|
75
|
+
* is skipped (transcripts come and go); only a failure to persist the
|
|
76
|
+
* scan state itself returns an Error.
|
|
77
|
+
*/
|
|
78
|
+
scan(): TokenUsageSummary | Error {
|
|
79
|
+
const data = this.store.read()
|
|
80
|
+
|
|
81
|
+
const entries = this.fs.readdirRecursiveSync(this.projectsDir)
|
|
82
|
+
if (entries instanceof Error) return toSummary(data)
|
|
83
|
+
|
|
84
|
+
for (const relativeFile of entries.filter((f) => f.endsWith(".jsonl"))) {
|
|
85
|
+
this.scanFile(join(this.projectsDir, relativeFile), data)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pruneOldDates(data.tokensByDate)
|
|
89
|
+
const writeError = this.store.write(data)
|
|
90
|
+
if (writeError) return writeError
|
|
91
|
+
return toSummary(data)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Persisted totals without touching the transcript files. */
|
|
95
|
+
summary(): TokenUsageSummary {
|
|
96
|
+
return toSummary(this.store.read())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private scanFile(path: string, data: UsageScanData): void {
|
|
100
|
+
const stat = this.fs.statSync(path)
|
|
101
|
+
if (stat instanceof Error) return
|
|
102
|
+
|
|
103
|
+
const known = data.files[path]
|
|
104
|
+
if (known && known.mtimeMs === stat.mtimeMs) return // unchanged since the last scan
|
|
105
|
+
if (!known && this.clock.millis() - stat.mtimeMs > BACKFILL_WINDOW_MS) return // old history — not worth reading
|
|
106
|
+
|
|
107
|
+
const content = this.fs.readFileSync(path)
|
|
108
|
+
if (content instanceof Error) return
|
|
109
|
+
|
|
110
|
+
// Filter blank lines (notably the trailing "" from a file that ends in
|
|
111
|
+
// "\n", which every appended JSONL record does) before indexing — a raw
|
|
112
|
+
// split() index would double-count that trailing blank as "consumed" and
|
|
113
|
+
// then permanently skip the next real line once the file grows again.
|
|
114
|
+
const lines = content.split("\n").filter((line) => line.length > 0)
|
|
115
|
+
const startIndex = known?.linesConsumed ?? 0
|
|
116
|
+
|
|
117
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
118
|
+
addLineUsage(lines[i] as string, data)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
data.files[path] = { mtimeMs: stat.mtimeMs, linesConsumed: lines.length }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Hand-rolled shape checks, not zod: this runs per transcript line, and a
|
|
126
|
+
// first-ever scan chews through hundreds of thousands of lines — zod here
|
|
127
|
+
// roughly doubles the post-JSON.parse cost for no safety gain over these
|
|
128
|
+
// typeof guards (a wrong-shaped line just contributes 0 tokens either way).
|
|
129
|
+
function addLineUsage(line: string, data: UsageScanData): void {
|
|
130
|
+
const parsed = safeJsonParse(line)
|
|
131
|
+
if (parsed instanceof Error) return
|
|
132
|
+
if (typeof parsed !== "object" || parsed === null) return
|
|
133
|
+
|
|
134
|
+
const usage = (parsed as { message?: { usage?: Record<string, unknown> } }).message?.usage
|
|
135
|
+
if (!usage || typeof usage !== "object") return
|
|
136
|
+
|
|
137
|
+
const tokens =
|
|
138
|
+
numberOr0(usage.input_tokens) +
|
|
139
|
+
numberOr0(usage.output_tokens) +
|
|
140
|
+
numberOr0(usage.cache_creation_input_tokens) +
|
|
141
|
+
numberOr0(usage.cache_read_input_tokens)
|
|
142
|
+
if (tokens <= 0) return
|
|
143
|
+
|
|
144
|
+
data.tokensTotal += tokens
|
|
145
|
+
|
|
146
|
+
// Bucketed by the transcript line's own (UTC) timestamp date, matching the
|
|
147
|
+
// UTC day boundary MinionStatsCollector uses for "today" — the two must
|
|
148
|
+
// agree, even though it means the day rolls over at UTC midnight rather
|
|
149
|
+
// than the user's local midnight.
|
|
150
|
+
const timestamp = (parsed as { timestamp?: unknown }).timestamp
|
|
151
|
+
const date = typeof timestamp === "string" ? timestamp.slice(0, 10) : undefined
|
|
152
|
+
if (date) data.tokensByDate[date] = (data.tokensByDate[date] ?? 0) + tokens
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function numberOr0(value: unknown): number {
|
|
156
|
+
return typeof value === "number" ? value : 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pruneOldDates(tokensByDate: Record<string, number>): void {
|
|
160
|
+
const dates = Object.keys(tokensByDate).sort()
|
|
161
|
+
const excess = dates.length - MAX_TRACKED_DATES
|
|
162
|
+
for (let i = 0; i < excess; i++) {
|
|
163
|
+
const date = dates[i]
|
|
164
|
+
if (date) delete tokensByDate[date]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toSummary(data: UsageScanData): TokenUsageSummary {
|
|
169
|
+
return { tokensTotal: data.tokensTotal, tokensByDate: { ...data.tokensByDate } }
|
|
170
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time boundary. Default NodeMinionClock returns `new Date()`; MemoryMinionClock
|
|
3
|
+
* is settable and `advance(ms)`-able for deterministic session-staleness / tick tests.
|
|
4
|
+
*/
|
|
5
|
+
export abstract class MinionClock {
|
|
6
|
+
abstract now(): Date
|
|
7
|
+
|
|
8
|
+
millis(): number {
|
|
9
|
+
return this.now().getTime()
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MinionClock } from "@lib/engine/time/clock"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
start?: Date
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class MemoryMinionClock extends MinionClock {
|
|
8
|
+
private current: Date
|
|
9
|
+
|
|
10
|
+
constructor(props: Props = {}) {
|
|
11
|
+
super()
|
|
12
|
+
this.current = props.start ?? new Date(0)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
now(): Date {
|
|
16
|
+
return new Date(this.current.getTime())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
set(date: Date): void {
|
|
20
|
+
this.current = date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
advance(ms: number): void {
|
|
24
|
+
this.current = new Date(this.current.getTime() + ms)
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Public API surface for the @shigureni/minion package.
|
|
2
|
+
//
|
|
3
|
+
// This is the programmable counterpart to the `minion` CLI — the CLI (cli/)
|
|
4
|
+
// is a thin Hono-routed consumer of the same `Minion` facade exported here.
|
|
5
|
+
//
|
|
6
|
+
// import { Minion } from "@shigureni/minion"
|
|
7
|
+
//
|
|
8
|
+
// const minion = new Minion()
|
|
9
|
+
// const result = await minion.app.start() // { kind: "started", pid: 123 } など
|
|
10
|
+
// console.log(minion.app.status().app.running)
|
|
11
|
+
//
|
|
12
|
+
// Pass `Minion.inMemory()` to get a fully sandboxed instance (no real disk,
|
|
13
|
+
// processes, or wall-clock time) for tests or ad-hoc experiments.
|
|
14
|
+
|
|
15
|
+
// Facade
|
|
16
|
+
export * from "@lib/minion"
|
|
17
|
+
|
|
18
|
+
// Error-as-value helpers — fallible operations return `T | Error`, never throw
|
|
19
|
+
export * from "@lib/engine/errors"
|
|
20
|
+
|
|
21
|
+
// App — build/start/kill/status for the Swift app + gateway daemon
|
|
22
|
+
export * from "@lib/engine/app/app-paths"
|
|
23
|
+
export * from "@lib/engine/app/app-runner"
|
|
24
|
+
export * from "@lib/engine/app/source-hash"
|
|
25
|
+
|
|
26
|
+
// Config — flat string-keyed `config.json` store
|
|
27
|
+
export * from "@lib/engine/config/config-store"
|
|
28
|
+
|
|
29
|
+
// Gateway — session watching + pet behavior + the in-process HTTP/WS server
|
|
30
|
+
export * from "@lib/engine/gateway/sessions"
|
|
31
|
+
export * from "@lib/engine/gateway/pet-behavior"
|
|
32
|
+
export * from "@lib/engine/gateway/gateway-routes"
|
|
33
|
+
export * from "@lib/engine/gateway/gateway-server"
|
|
34
|
+
export * from "@lib/engine/gateway/resolve-daemon-script"
|
|
35
|
+
|
|
36
|
+
// Stats — session/token tracking feeding achievement + minion-species conditions
|
|
37
|
+
export * from "@lib/engine/stats/stats-snapshot"
|
|
38
|
+
export * from "@lib/engine/stats/session-stats-tracker"
|
|
39
|
+
export * from "@lib/engine/stats/token-usage-tracker"
|
|
40
|
+
export * from "@lib/engine/stats/stats-collector"
|
|
41
|
+
|
|
42
|
+
// Collection — the minion dex: rarity-tiered species + achievements, unlocked over time
|
|
43
|
+
export * from "@lib/engine/collection/moon"
|
|
44
|
+
export * from "@lib/engine/collection/species"
|
|
45
|
+
export * from "@lib/engine/collection/achievements"
|
|
46
|
+
export * from "@lib/engine/collection/collection-store"
|
|
47
|
+
export * from "@lib/engine/collection/collection-tracker"
|
|
48
|
+
|
|
49
|
+
// IO boundaries (abstract + Node / Memory implementations)
|
|
50
|
+
export * from "@lib/engine/fs/file-system"
|
|
51
|
+
export * from "@lib/engine/fs/node-file-system"
|
|
52
|
+
export * from "@lib/engine/fs/memory-file-system"
|
|
53
|
+
export * from "@lib/engine/fs/json-file-store"
|
|
54
|
+
|
|
55
|
+
export * from "@lib/engine/process/process-runner"
|
|
56
|
+
export * from "@lib/engine/process/node-process-runner"
|
|
57
|
+
export * from "@lib/engine/process/memory-process-runner"
|
|
58
|
+
|
|
59
|
+
export * from "@lib/engine/time/clock"
|
|
60
|
+
export * from "@lib/engine/time/node-clock"
|
|
61
|
+
export * from "@lib/engine/time/memory-clock"
|
|
62
|
+
|
|
63
|
+
export * from "@lib/engine/random/random-source"
|
|
64
|
+
export * from "@lib/engine/random/node-random-source"
|
|
65
|
+
export * from "@lib/engine/random/memory-random-source"
|