@shigureni/minion 0.1.0 → 0.2.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 +136 -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 +2 -1
- package/tsconfig.json +3 -2
- package/cli/src/gateway.ts +0 -204
- package/cli/src/lib/process.ts +0 -204
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { isFullMoon, isNewMoon } from "@lib/engine/collection/moon"
|
|
2
|
+
import type { StatsSnapshot } from "@lib/engine/stats/stats-snapshot"
|
|
3
|
+
|
|
4
|
+
export type MinionRarity = "common" | "rare"
|
|
5
|
+
|
|
6
|
+
export type MinionSpecies = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
rarity: MinionRarity
|
|
10
|
+
description: string
|
|
11
|
+
condition: (stats: StatsSnapshot) => boolean
|
|
12
|
+
/**
|
|
13
|
+
* Opaque reference to this species' art (a sprite path, bundle key, URL —
|
|
14
|
+
* whatever scheme the host app's renderer uses). This library never reads
|
|
15
|
+
* or interprets it; it just carries it through `dex()` for a host to
|
|
16
|
+
* consume. Left `undefined` in the built-in catalog — pass your own
|
|
17
|
+
* catalog (see `MinionCollectionTracker`'s `species` prop) to fill it in.
|
|
18
|
+
*/
|
|
19
|
+
asset?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The built-in minion species catalog. `resolveSpecies` picks the first
|
|
24
|
+
* entry whose condition matches, so order is priority: rare/specific
|
|
25
|
+
* conditions are listed first, and the five time-of-day commons — which
|
|
26
|
+
* together cover every hour — sit last as the guaranteed fallback.
|
|
27
|
+
*
|
|
28
|
+
* This is only the default; pass a custom `species` array to
|
|
29
|
+
* `MinionCollectionTracker` (or `Minion`) to replace it entirely — e.g. to
|
|
30
|
+
* add your own species, retheme the built-ins, or attach real `asset`
|
|
31
|
+
* references once art exists.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MINION_SPECIES: MinionSpecies[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "zorome",
|
|
36
|
+
name: "ゾロめミニオン",
|
|
37
|
+
rarity: "rare",
|
|
38
|
+
description: "時計が11:11か22:22を指す瞬間だけ現れる。",
|
|
39
|
+
condition: (s) => {
|
|
40
|
+
const minute = s.now.getMinutes()
|
|
41
|
+
return (s.hour === 11 && minute === 11) || (s.hour === 22 && minute === 22)
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "full-moon",
|
|
46
|
+
name: "まんげつミニオン",
|
|
47
|
+
rarity: "rare",
|
|
48
|
+
description: "満月の夜(20時〜5時)に現れる。",
|
|
49
|
+
condition: (s) =>
|
|
50
|
+
isFullMoon(s.now) && (s.timeBucket === "night" || s.timeBucket === "lateNight"),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "new-moon",
|
|
54
|
+
name: "しんげつミニオン",
|
|
55
|
+
rarity: "rare",
|
|
56
|
+
description: "新月の深夜、月のない暗い夜に現れる。",
|
|
57
|
+
condition: (s) => isNewMoon(s.now) && s.timeBucket === "lateNight",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "mega-swarm",
|
|
61
|
+
name: "だいぐんせいミニオン",
|
|
62
|
+
rarity: "rare",
|
|
63
|
+
description: "同時に10以上のセッションが動く修羅場に現れる。",
|
|
64
|
+
condition: (s) => s.currentConcurrentSessions >= 10,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "swarm",
|
|
68
|
+
name: "ぐんせいミニオン",
|
|
69
|
+
rarity: "rare",
|
|
70
|
+
description: "同時に5つ以上のセッションが動いているときだけ現れる。",
|
|
71
|
+
condition: (s) => s.currentConcurrentSessions >= 5,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "overdrive",
|
|
75
|
+
name: "オーバードライブミニオン",
|
|
76
|
+
rarity: "rare",
|
|
77
|
+
description: "同時に3つ以上のセッションが動いているときに現れる。",
|
|
78
|
+
condition: (s) => s.currentConcurrentSessions >= 3,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "insomniac",
|
|
82
|
+
name: "ふみんミニオン",
|
|
83
|
+
rarity: "rare",
|
|
84
|
+
description: "深夜(0時〜5時)にセッションを動かしていると現れる。",
|
|
85
|
+
condition: (s) => s.timeBucket === "lateNight" && s.currentConcurrentSessions >= 1,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "morning-active",
|
|
89
|
+
name: "あさかつミニオン",
|
|
90
|
+
rarity: "rare",
|
|
91
|
+
description: "朝(5時〜10時)にセッションを動かしていると現れる。",
|
|
92
|
+
condition: (s) => s.timeBucket === "morning" && s.currentConcurrentSessions >= 1,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "lunch-break",
|
|
96
|
+
name: "おひるミニオン",
|
|
97
|
+
rarity: "rare",
|
|
98
|
+
description: "お昼(12時台)にセッションを動かしていると現れる。",
|
|
99
|
+
condition: (s) => s.hour === 12 && s.currentConcurrentSessions >= 1,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "twins",
|
|
103
|
+
name: "ふたごミニオン",
|
|
104
|
+
rarity: "rare",
|
|
105
|
+
description: "同時に2つのセッションが動いていると現れる。",
|
|
106
|
+
condition: (s) => s.currentConcurrentSessions >= 2,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "legend",
|
|
110
|
+
name: "でんせつミニオン",
|
|
111
|
+
rarity: "rare",
|
|
112
|
+
description: "累計1億トークンを消費したでんせつのもとに現れる。",
|
|
113
|
+
condition: (s) => s.tokensTotal >= 100_000_000,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "torrent",
|
|
117
|
+
name: "トレントミニオン",
|
|
118
|
+
rarity: "rare",
|
|
119
|
+
description: "1週間で500万トークンを消費すると現れる。",
|
|
120
|
+
condition: (s) => s.tokensThisWeek >= 5_000_000,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "golden",
|
|
124
|
+
name: "こがねミニオン",
|
|
125
|
+
rarity: "rare",
|
|
126
|
+
description: "累計1000万トークンを消費した猛者に現れる。",
|
|
127
|
+
condition: (s) => s.tokensTotal >= 10_000_000,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "big-spender",
|
|
131
|
+
name: "だいしょうひミニオン",
|
|
132
|
+
rarity: "rare",
|
|
133
|
+
description: "1日で100万トークンを消費すると現れる。",
|
|
134
|
+
condition: (s) => s.tokensToday >= 1_000_000,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "glutton",
|
|
138
|
+
name: "おおぐいミニオン",
|
|
139
|
+
rarity: "rare",
|
|
140
|
+
description: "1日で30万トークンを消費すると現れる。",
|
|
141
|
+
condition: (s) => s.tokensToday >= 300_000,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "perfect-attendance",
|
|
145
|
+
name: "かいきんミニオン",
|
|
146
|
+
rarity: "rare",
|
|
147
|
+
description: "30日連続でセッションを実行していると現れる。",
|
|
148
|
+
condition: (s) => s.currentStreakDays >= 30,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "marathon",
|
|
152
|
+
name: "マラソンミニオン",
|
|
153
|
+
rarity: "rare",
|
|
154
|
+
description: "7日連続でセッションを実行していると現れる。",
|
|
155
|
+
condition: (s) => s.currentStreakDays >= 7,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "three-day-streak",
|
|
159
|
+
name: "みっかミニオン",
|
|
160
|
+
rarity: "rare",
|
|
161
|
+
description: "3日連続でセッションを実行していると現れる。みっかぼうず卒業。",
|
|
162
|
+
condition: (s) => s.currentStreakDays >= 3,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "month-end",
|
|
166
|
+
name: "しめきりミニオン",
|
|
167
|
+
rarity: "rare",
|
|
168
|
+
description: "月末の日に現れる。しめきりは大丈夫?",
|
|
169
|
+
condition: (s) =>
|
|
170
|
+
new Date(s.now.getFullYear(), s.now.getMonth(), s.now.getDate() + 1).getDate() === 1,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "friday-night",
|
|
174
|
+
name: "きんようミニオン",
|
|
175
|
+
rarity: "rare",
|
|
176
|
+
description: "金曜の夜(20時〜24時)にセッションを動かしていると現れる。",
|
|
177
|
+
condition: (s) =>
|
|
178
|
+
s.now.getDay() === 5 && s.timeBucket === "night" && s.currentConcurrentSessions >= 1,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "sunday-worker",
|
|
182
|
+
name: "にちようミニオン",
|
|
183
|
+
rarity: "rare",
|
|
184
|
+
description: "日曜にセッションを動かしていると現れる。",
|
|
185
|
+
condition: (s) => s.now.getDay() === 0 && s.currentConcurrentSessions >= 1,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "devoted",
|
|
189
|
+
name: "いちずミニオン",
|
|
190
|
+
rarity: "rare",
|
|
191
|
+
description: "累計50セッションを超えてもプロジェクトがひとつだけだと現れる。",
|
|
192
|
+
condition: (s) => s.uniqueProjectsSeen === 1 && s.totalSessionsSeen >= 50,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "wanderer",
|
|
196
|
+
name: "ほうろうミニオン",
|
|
197
|
+
rarity: "rare",
|
|
198
|
+
description: "10個以上の異なるプロジェクトを渡り歩くと現れる。",
|
|
199
|
+
condition: (s) => s.uniqueProjectsSeen >= 10,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "juggler",
|
|
203
|
+
name: "かけもちミニオン",
|
|
204
|
+
rarity: "rare",
|
|
205
|
+
description: "3つ以上のプロジェクトをかけもちすると現れる。",
|
|
206
|
+
condition: (s) => s.uniqueProjectsSeen >= 3,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "veteran",
|
|
210
|
+
name: "ベテランミニオン",
|
|
211
|
+
rarity: "rare",
|
|
212
|
+
description: "累計100セッションを超えたころに現れる。",
|
|
213
|
+
condition: (s) => s.totalSessionsSeen >= 100,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "rookie",
|
|
217
|
+
name: "ひよこミニオン",
|
|
218
|
+
rarity: "rare",
|
|
219
|
+
description: "累計10セッションを実行したかけだしに現れる。",
|
|
220
|
+
condition: (s) => s.totalSessionsSeen >= 10,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: "late-night",
|
|
224
|
+
name: "しんやミニオン",
|
|
225
|
+
rarity: "common",
|
|
226
|
+
description: "深夜(0時〜5時)に現れる。",
|
|
227
|
+
condition: (s) => s.timeBucket === "lateNight",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "morning",
|
|
231
|
+
name: "あさミニオン",
|
|
232
|
+
rarity: "common",
|
|
233
|
+
description: "朝(5時〜10時)に現れる。",
|
|
234
|
+
condition: (s) => s.timeBucket === "morning",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "day",
|
|
238
|
+
name: "ひなたミニオン",
|
|
239
|
+
rarity: "common",
|
|
240
|
+
description: "日中(10時〜17時)に現れる。",
|
|
241
|
+
condition: (s) => s.timeBucket === "day",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "evening",
|
|
245
|
+
name: "ゆうやけミニオン",
|
|
246
|
+
rarity: "common",
|
|
247
|
+
description: "夕方(17時〜20時)に現れる。",
|
|
248
|
+
condition: (s) => s.timeBucket === "evening",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "night",
|
|
252
|
+
name: "よふかしミニオン",
|
|
253
|
+
rarity: "common",
|
|
254
|
+
description: "夜(20時〜24時)に現れる。",
|
|
255
|
+
condition: (s) => s.timeBucket === "night",
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The species that manifests right now, per `stats`. Checks `catalog` in
|
|
261
|
+
* order and returns the first match — defaults to `DEFAULT_MINION_SPECIES`,
|
|
262
|
+
* whose time-of-day commons cover every hour so it always resolves. A custom
|
|
263
|
+
* catalog must include an unconditional (or otherwise fully-covering)
|
|
264
|
+
* fallback entry, or this returns an Error when nothing matches.
|
|
265
|
+
*/
|
|
266
|
+
export function resolveSpecies(
|
|
267
|
+
stats: StatsSnapshot,
|
|
268
|
+
catalog: MinionSpecies[] = DEFAULT_MINION_SPECIES,
|
|
269
|
+
): MinionSpecies | Error {
|
|
270
|
+
const found = catalog.find((species) => species.condition(stats))
|
|
271
|
+
if (!found) {
|
|
272
|
+
return new Error("no minion species matched — the catalog needs a fully-covering fallback")
|
|
273
|
+
}
|
|
274
|
+
return found
|
|
275
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
fs: MinionFileSystem
|
|
7
|
+
path: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const schema = z.record(z.string(), z.string())
|
|
11
|
+
|
|
12
|
+
const EMPTY: Record<string, string> = {}
|
|
13
|
+
|
|
14
|
+
/** Reads/writes the flat string-keyed `config.json` (`minion config get/set/list`). */
|
|
15
|
+
export class MinionConfigStore {
|
|
16
|
+
private readonly store: JsonFileStore<Record<string, string>>
|
|
17
|
+
|
|
18
|
+
constructor(props: Props) {
|
|
19
|
+
this.store = new JsonFileStore({ fs: props.fs, path: props.path, schema, defaultValue: EMPTY })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
list(): Record<string, string> {
|
|
23
|
+
return this.store.read()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns `undefined` when the key is unset — distinguishable from an empty-string value. */
|
|
27
|
+
get(key: string): string | undefined {
|
|
28
|
+
return this.list()[key]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set(key: string, value: string): Error | null {
|
|
32
|
+
const config = this.list()
|
|
33
|
+
config[key] = value
|
|
34
|
+
return this.store.write(config)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error-as-value helpers. The library never throws across its public surface —
|
|
3
|
+
* fallible operations return `T | Error`, checked with `instanceof Error`.
|
|
4
|
+
* These helpers convert the throwing world (node:fs, Bun, JSON.parse) into
|
|
5
|
+
* that convention at the boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Normalizes a caught value into an Error without losing the original message. */
|
|
9
|
+
export function toError(thrown: unknown): Error {
|
|
10
|
+
return thrown instanceof Error ? thrown : new Error(String(thrown))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `JSON.parse` without the throw. JSON can never encode an `Error` instance, so `instanceof Error` cleanly separates failure. */
|
|
14
|
+
export function safeJsonParse(text: string): unknown {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text)
|
|
17
|
+
} catch (thrown) {
|
|
18
|
+
return toError(thrown)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type MinionFileStat = {
|
|
2
|
+
mtimeMs: number
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filesystem boundary used everywhere minion reads or writes.
|
|
7
|
+
* Default is NodeMinionFileSystem (real `node:fs`); MemoryMinionFileSystem
|
|
8
|
+
* provides a sandbox for tests and embedded use.
|
|
9
|
+
*
|
|
10
|
+
* Fallible operations return `T | Error` (checked with `instanceof Error`)
|
|
11
|
+
* instead of throwing — implementations must catch their runtime's exceptions
|
|
12
|
+
* at this boundary.
|
|
13
|
+
*/
|
|
14
|
+
export abstract class MinionFileSystem {
|
|
15
|
+
abstract existsSync(path: string): boolean
|
|
16
|
+
abstract readFileSync(path: string): string | Error
|
|
17
|
+
abstract writeFileSync(path: string, data: string): Error | null
|
|
18
|
+
abstract mkdirSync(path: string, options?: { recursive?: boolean }): Error | null
|
|
19
|
+
abstract rmSync(path: string, options?: { force?: boolean }): Error | null
|
|
20
|
+
abstract readdirSync(path: string): string[] | Error
|
|
21
|
+
/** Recursively list files under `path`, as `/`-joined paths relative to `path`. */
|
|
22
|
+
abstract readdirRecursiveSync(path: string): string[] | Error
|
|
23
|
+
/**
|
|
24
|
+
* Atomically create `path` only if it does not already exist (O_EXCL).
|
|
25
|
+
* Returns `true` if this call created it, `false` if it already existed.
|
|
26
|
+
* Used for the start-lock / pid-file singleton guard.
|
|
27
|
+
*/
|
|
28
|
+
abstract createExclusiveSync(path: string): boolean
|
|
29
|
+
/** Used to detect whether a file changed since it was last scanned (e.g. transcript token-usage scanning). */
|
|
30
|
+
abstract statSync(path: string): MinionFileStat | Error
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { dirname } from "node:path"
|
|
2
|
+
import type { z } from "zod"
|
|
3
|
+
import { safeJsonParse } from "@lib/engine/errors"
|
|
4
|
+
import type { MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
5
|
+
|
|
6
|
+
type Props<T> = {
|
|
7
|
+
fs: MinionFileSystem
|
|
8
|
+
path: string
|
|
9
|
+
/**
|
|
10
|
+
* Runtime shape guard for what comes back off disk — `JSON.parse` alone
|
|
11
|
+
* would let a hand-edited or version-skewed file flow through as `T` and
|
|
12
|
+
* crash later. Give evolving schemas per-field `.catch()` defaults so a
|
|
13
|
+
* file written by an older version backfills instead of falling back wholesale.
|
|
14
|
+
*/
|
|
15
|
+
schema: z.ZodType<T>
|
|
16
|
+
defaultValue: T
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads/writes a single JSON value at `path`, creating parent directories as
|
|
21
|
+
* needed. Falls back to a fresh clone of `defaultValue` when the file is
|
|
22
|
+
* missing, unreadable, corrupt, or fails `schema` validation. Shared by every
|
|
23
|
+
* `~/.minion/*.json` store (config, session stats, token usage, collection)
|
|
24
|
+
* to avoid re-deriving the same read/parse/validate/write boilerplate in each one.
|
|
25
|
+
*/
|
|
26
|
+
export class JsonFileStore<T> {
|
|
27
|
+
private readonly fs: MinionFileSystem
|
|
28
|
+
private readonly path: string
|
|
29
|
+
private readonly schema: z.ZodType<T>
|
|
30
|
+
private readonly defaultValue: T
|
|
31
|
+
|
|
32
|
+
constructor(props: Props<T>) {
|
|
33
|
+
this.fs = props.fs
|
|
34
|
+
this.path = props.path
|
|
35
|
+
this.schema = props.schema
|
|
36
|
+
this.defaultValue = props.defaultValue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
read(): T {
|
|
40
|
+
if (!this.fs.existsSync(this.path)) return structuredClone(this.defaultValue)
|
|
41
|
+
|
|
42
|
+
const content = this.fs.readFileSync(this.path)
|
|
43
|
+
if (content instanceof Error) return structuredClone(this.defaultValue)
|
|
44
|
+
|
|
45
|
+
const json = safeJsonParse(content)
|
|
46
|
+
if (json instanceof Error) return structuredClone(this.defaultValue)
|
|
47
|
+
|
|
48
|
+
const result = this.schema.safeParse(json)
|
|
49
|
+
return result.success ? result.data : structuredClone(this.defaultValue)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
write(value: T): Error | null {
|
|
53
|
+
const mkdirError = this.fs.mkdirSync(dirname(this.path), { recursive: true })
|
|
54
|
+
if (mkdirError) return mkdirError
|
|
55
|
+
return this.fs.writeFileSync(this.path, JSON.stringify(value, null, 2))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type MinionFileStat, MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
dirs?: string[]
|
|
5
|
+
files?: Record<string, string>
|
|
6
|
+
mtimes?: Record<string, number>
|
|
7
|
+
/** Clock used to stamp mtimes on write, when a file's mtime isn't set explicitly via `setMtime`. Defaults to `Date.now`. */
|
|
8
|
+
now?: () => number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class MemoryMinionFileSystem extends MinionFileSystem {
|
|
12
|
+
private readonly dirs: Set<string>
|
|
13
|
+
private readonly files: Map<string, string>
|
|
14
|
+
private readonly mtimes: Map<string, number>
|
|
15
|
+
private readonly now: () => number
|
|
16
|
+
|
|
17
|
+
constructor(props: Props = {}) {
|
|
18
|
+
super()
|
|
19
|
+
this.dirs = new Set(props.dirs ?? [])
|
|
20
|
+
this.files = new Map(Object.entries(props.files ?? {}))
|
|
21
|
+
this.mtimes = new Map(Object.entries(props.mtimes ?? {}))
|
|
22
|
+
this.now = props.now ?? (() => Date.now())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
existsSync(path: string): boolean {
|
|
26
|
+
return this.dirs.has(path) || this.files.has(path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
readFileSync(path: string): string | Error {
|
|
30
|
+
const data = this.files.get(path)
|
|
31
|
+
if (data === undefined) return new Error(`not found: ${path}`)
|
|
32
|
+
return data
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
writeFileSync(path: string, data: string): Error | null {
|
|
36
|
+
this.files.set(path, data)
|
|
37
|
+
this.touch(path)
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mkdirSync(path: string, options?: { recursive?: boolean }): Error | null {
|
|
42
|
+
void options
|
|
43
|
+
this.dirs.add(path)
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
rmSync(path: string, options?: { force?: boolean }): Error | null {
|
|
48
|
+
if (!options?.force && !this.existsSync(path)) {
|
|
49
|
+
return new Error(`not found: ${path}`)
|
|
50
|
+
}
|
|
51
|
+
this.files.delete(path)
|
|
52
|
+
this.dirs.delete(path)
|
|
53
|
+
this.mtimes.delete(path)
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
readdirSync(path: string): string[] | Error {
|
|
58
|
+
const prefix = path.endsWith("/") ? path : `${path}/`
|
|
59
|
+
const names = new Set<string>()
|
|
60
|
+
|
|
61
|
+
for (const file of this.files.keys()) {
|
|
62
|
+
if (!file.startsWith(prefix)) continue
|
|
63
|
+
const rest = file.slice(prefix.length)
|
|
64
|
+
const [first] = rest.split("/")
|
|
65
|
+
if (first) names.add(first)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Array.from(names)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns file paths only (no directory entries), unlike node:fs's recursive readdirSync. */
|
|
72
|
+
readdirRecursiveSync(path: string): string[] | Error {
|
|
73
|
+
const prefix = path.endsWith("/") ? path : `${path}/`
|
|
74
|
+
const names: string[] = []
|
|
75
|
+
|
|
76
|
+
for (const file of this.files.keys()) {
|
|
77
|
+
if (!file.startsWith(prefix)) continue
|
|
78
|
+
names.push(file.slice(prefix.length))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return names
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
createExclusiveSync(path: string): boolean {
|
|
85
|
+
if (this.files.has(path)) return false
|
|
86
|
+
this.files.set(path, "")
|
|
87
|
+
this.touch(path)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
statSync(path: string): MinionFileStat | Error {
|
|
92
|
+
if (!this.files.has(path)) return new Error(`not found: ${path}`)
|
|
93
|
+
const mtimeMs = this.mtimes.get(path)
|
|
94
|
+
if (mtimeMs === undefined) this.touch(path)
|
|
95
|
+
return { mtimeMs: this.mtimes.get(path) ?? this.now() }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Sets a file's mtime explicitly — useful for asserting "unchanged since last scan" behavior in tests. */
|
|
99
|
+
setMtime(path: string, mtimeMs: number): void {
|
|
100
|
+
this.mtimes.set(path, mtimeMs)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private touch(path: string): void {
|
|
104
|
+
this.mtimes.set(path, this.now())
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs"
|
|
12
|
+
import { toError } from "@lib/engine/errors"
|
|
13
|
+
import { type MinionFileStat, MinionFileSystem } from "@lib/engine/fs/file-system"
|
|
14
|
+
|
|
15
|
+
export class NodeMinionFileSystem extends MinionFileSystem {
|
|
16
|
+
existsSync(path: string): boolean {
|
|
17
|
+
return existsSync(path)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
readFileSync(path: string): string | Error {
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(path, "utf8")
|
|
23
|
+
} catch (thrown) {
|
|
24
|
+
return toError(thrown)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
writeFileSync(path: string, data: string): Error | null {
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(path, data)
|
|
31
|
+
return null
|
|
32
|
+
} catch (thrown) {
|
|
33
|
+
return toError(thrown)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mkdirSync(path: string, options?: { recursive?: boolean }): Error | null {
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(path, { recursive: options?.recursive ?? false })
|
|
40
|
+
return null
|
|
41
|
+
} catch (thrown) {
|
|
42
|
+
return toError(thrown)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
rmSync(path: string, options?: { force?: boolean }): Error | null {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(path, { force: options?.force ?? false })
|
|
49
|
+
return null
|
|
50
|
+
} catch (thrown) {
|
|
51
|
+
return toError(thrown)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
readdirSync(path: string): string[] | Error {
|
|
56
|
+
try {
|
|
57
|
+
return readdirSync(path)
|
|
58
|
+
} catch (thrown) {
|
|
59
|
+
return toError(thrown)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
readdirRecursiveSync(path: string): string[] | Error {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(path, { recursive: true }) as string[]
|
|
66
|
+
} catch (thrown) {
|
|
67
|
+
return toError(thrown)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
createExclusiveSync(path: string): boolean {
|
|
72
|
+
try {
|
|
73
|
+
closeSync(openSync(path, "wx"))
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
statSync(path: string): MinionFileStat | Error {
|
|
81
|
+
try {
|
|
82
|
+
return { mtimeMs: statSync(path).mtimeMs }
|
|
83
|
+
} catch (thrown) {
|
|
84
|
+
return toError(thrown)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Minion } from "@lib/minion"
|
|
3
|
+
|
|
4
|
+
const minion = new Minion()
|
|
5
|
+
const port = Number(process.env.MINION_GATEWAY_PORT) || undefined
|
|
6
|
+
|
|
7
|
+
const handle = minion.gatewayServer({ port }).start()
|
|
8
|
+
|
|
9
|
+
if (handle instanceof Error) {
|
|
10
|
+
console.error(`gateway failed to start: ${handle.message}`)
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
console.log(`gateway listening on :${handle.port}`)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import type { PetBehaviorEngine } from "@lib/engine/gateway/pet-behavior"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /sessions — the current pet snapshot as JSON. Kept as a plain Hono app
|
|
6
|
+
* (rather than inlined into `Bun.serve`'s fetch handler) so it can be
|
|
7
|
+
* exercised with `.request()` in tests without a real Bun server. The `/ws`
|
|
8
|
+
* upgrade path lives in gateway-server.ts since it needs the raw Bun server
|
|
9
|
+
* instance to call `server.upgrade(req)`.
|
|
10
|
+
*/
|
|
11
|
+
export function buildGatewayRoutes(engine: PetBehaviorEngine) {
|
|
12
|
+
const app = new Hono()
|
|
13
|
+
app.get("/sessions", (c) => c.json({ sessions: engine.snapshot() }))
|
|
14
|
+
return app
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GatewayRoutesApp = ReturnType<typeof buildGatewayRoutes>
|