@stage5/lumine 0.2.24 → 0.2.26
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 +1 -0
- package/lib/admin.js +85 -1
- package/lib/commands.js +2 -0
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +161 -4
package/README.md
CHANGED
|
@@ -180,6 +180,7 @@ lumine admin featured list --json
|
|
|
180
180
|
lumine admin subject feature 123 --json
|
|
181
181
|
lumine admin subject unfeature 123 --json
|
|
182
182
|
lumine admin featured reorder --subject-ids 30,20,10 --json
|
|
183
|
+
lumine admin brief --days 3 --json
|
|
183
184
|
lumine admin post recommend comment:456 --anyone-can-reward --reward-twinkles 3 --json
|
|
184
185
|
lumine admin post reward comment:456 --twinkles 3 --json
|
|
185
186
|
lumine admin daily-run complete --json
|
package/lib/admin.js
CHANGED
|
@@ -4,6 +4,36 @@ import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
|
4
4
|
import { requestJson } from "./http.js";
|
|
5
5
|
|
|
6
6
|
const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
|
|
7
|
+
const MAX_COMPOSED_COMMENT_FILE_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_COMPOSED_COMMENT_LENGTH = 10_000;
|
|
9
|
+
|
|
10
|
+
// Operator-composed persona comment text (plain UTF-8, not JSON). The agent
|
|
11
|
+
// writes the comment in the bot's persona itself; the server never invokes
|
|
12
|
+
// its model and no AI Energy is spent.
|
|
13
|
+
function readComposedCommentFile(filePath) {
|
|
14
|
+
const normalizedPath = String(filePath || "").trim();
|
|
15
|
+
let contents;
|
|
16
|
+
try {
|
|
17
|
+
contents = readFileSync(normalizedPath, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
throw cliValidationError(`Could not read ${normalizedPath}.`);
|
|
20
|
+
}
|
|
21
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_COMPOSED_COMMENT_FILE_BYTES) {
|
|
22
|
+
throw cliValidationError("The composed comment file must be under 64KB.");
|
|
23
|
+
}
|
|
24
|
+
const normalized = contents.trim();
|
|
25
|
+
if (!normalized) {
|
|
26
|
+
throw cliValidationError(
|
|
27
|
+
`${normalizedPath} is empty; a composed comment needs text.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (normalized.length > MAX_COMPOSED_COMMENT_LENGTH) {
|
|
31
|
+
throw cliValidationError(
|
|
32
|
+
`A composed comment must be at most ${MAX_COMPOSED_COMMENT_LENGTH} characters.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
7
37
|
|
|
8
38
|
function readEditorialFile(filePath) {
|
|
9
39
|
const normalizedPath = String(filePath || "").trim();
|
|
@@ -126,6 +156,15 @@ export async function adminCommand(options) {
|
|
|
126
156
|
}
|
|
127
157
|
throw error;
|
|
128
158
|
}
|
|
159
|
+
if (
|
|
160
|
+
operation.name === "comment.draft" &&
|
|
161
|
+
typeof operation.body?.content === "string"
|
|
162
|
+
) {
|
|
163
|
+
assertComposedCommentDraftResult({
|
|
164
|
+
result,
|
|
165
|
+
expectedContent: operation.body.content,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
129
168
|
if (options.json) {
|
|
130
169
|
console.log(JSON.stringify(result));
|
|
131
170
|
return result;
|
|
@@ -134,6 +173,35 @@ export async function adminCommand(options) {
|
|
|
134
173
|
return result;
|
|
135
174
|
}
|
|
136
175
|
|
|
176
|
+
export function assertComposedCommentDraftResult({
|
|
177
|
+
result,
|
|
178
|
+
expectedContent,
|
|
179
|
+
}) {
|
|
180
|
+
const draft = result?.data?.draft;
|
|
181
|
+
if (
|
|
182
|
+
draft?.decision === "draft" &&
|
|
183
|
+
draft?.reason === "operator-composed" &&
|
|
184
|
+
draft?.content === expectedContent &&
|
|
185
|
+
draft?.status === "ready"
|
|
186
|
+
) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const error = new Error(
|
|
190
|
+
"The API did not confirm the operator-composed draft. Stop without publishing it and deploy an API that supports composed drafts.",
|
|
191
|
+
);
|
|
192
|
+
error.code = "LUMINE_ADMIN_COMPOSED_COMMENT_UNSUPPORTED";
|
|
193
|
+
error.data = {
|
|
194
|
+
ok: false,
|
|
195
|
+
status: "validation_error",
|
|
196
|
+
error: {
|
|
197
|
+
code: error.code,
|
|
198
|
+
message: error.message,
|
|
199
|
+
details: null,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
|
|
137
205
|
const RECOMMENDATION_CONTENT_TYPES = new Map([
|
|
138
206
|
["comment", "comment"],
|
|
139
207
|
["aistory", "aiStory"],
|
|
@@ -512,6 +580,19 @@ export function parseAdminOperation(options) {
|
|
|
512
580
|
);
|
|
513
581
|
}
|
|
514
582
|
|
|
583
|
+
if (namespace === "brief" && !action) {
|
|
584
|
+
if (options.adminDays) {
|
|
585
|
+
const days = Number(options.adminDays);
|
|
586
|
+
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
|
587
|
+
throw cliValidationError("--days must be an integer between 1 and 30.");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return readOperation(
|
|
591
|
+
"insights.brief",
|
|
592
|
+
withQuery("/cli/admin/insights/brief", { days: options.adminDays }),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
515
596
|
if (namespace === "audit" && (!action || action === "list")) {
|
|
516
597
|
const runFilter = String(options.adminRun || "").trim();
|
|
517
598
|
if (runFilter && !["current", "last"].includes(runFilter)) {
|
|
@@ -551,6 +632,9 @@ export function parseAdminOperation(options) {
|
|
|
551
632
|
identity: options.adminIdentity
|
|
552
633
|
? parseIdentity(options.adminIdentity)
|
|
553
634
|
: undefined,
|
|
635
|
+
...(options.adminFile
|
|
636
|
+
? { content: readComposedCommentFile(options.adminFile) }
|
|
637
|
+
: {}),
|
|
554
638
|
},
|
|
555
639
|
);
|
|
556
640
|
}
|
|
@@ -570,7 +654,7 @@ export function parseAdminOperation(options) {
|
|
|
570
654
|
}
|
|
571
655
|
|
|
572
656
|
throw cliValidationError(
|
|
573
|
-
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit ...",
|
|
657
|
+
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit|brief ...",
|
|
574
658
|
);
|
|
575
659
|
}
|
|
576
660
|
|
package/lib/commands.js
CHANGED
|
@@ -2188,6 +2188,7 @@ export function parseArgs(args) {
|
|
|
2188
2188
|
adminTarget: raw.target ? String(raw.target) : "",
|
|
2189
2189
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2190
2190
|
adminDate: raw.date ? String(raw.date) : "",
|
|
2191
|
+
adminDays: raw.days ? String(raw.days) : "",
|
|
2191
2192
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
2192
2193
|
adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
|
|
2193
2194
|
adminFile: raw.file ? String(raw.file) : "",
|
|
@@ -2525,6 +2526,7 @@ export function printHelp() {
|
|
|
2525
2526
|
lumine admin comment draft <target> [--type subject|comment|aiStory|dailyReflection] [--identity zero|ciel|auto] [--json]
|
|
2526
2527
|
lumine admin comment reply comment:<id> [--identity zero|ciel|auto] [--json]
|
|
2527
2528
|
lumine admin comment post --draft-id <id> [--json]
|
|
2529
|
+
lumine admin brief [--days <1..30>] [--json]
|
|
2528
2530
|
lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--cursor <cursor>] [--json]
|
|
2529
2531
|
|
|
2530
2532
|
Examples:
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -103,6 +103,15 @@ posts that most need Zero or Ciel are the ones nobody else answered.
|
|
|
103
103
|
it pairs well with a warm comment.
|
|
104
104
|
- **Featured still selects for quality**, but when two candidates are close,
|
|
105
105
|
prefer the child who has never been featured over the one who has.
|
|
106
|
+
- **The live Featured board is Mikey's word.** Do not remove a currently
|
|
107
|
+
Featured subject without first showing Mikey the planned removals and
|
|
108
|
+
replacements and getting his go-ahead. In the other direction, if a subject
|
|
109
|
+
that was Featured or pinned during an earlier run is no longer on
|
|
110
|
+
`featured list`, treat that as Mikey having removed it deliberately — never
|
|
111
|
+
re-feature it to "restore" the board, and never treat any subject as a
|
|
112
|
+
permanent fixture from memory or old run notes. Derive the board fresh from
|
|
113
|
+
`featured list` at the start of every run; the only pins that exist are the
|
|
114
|
+
ones currently on it.
|
|
106
115
|
|
|
107
116
|
Sensitive disclosures, active disputes, and anything needing crisis or medical
|
|
108
117
|
judgment remain out of scope for a bot comment no matter how neglected the post
|
|
@@ -1027,6 +1036,116 @@ type NewsClaim = Success<{
|
|
|
1027
1036
|
type NewsSubmit = NewsStatus; // "success"; newspaper includes revisionNumber
|
|
1028
1037
|
```
|
|
1029
1038
|
|
|
1039
|
+
## Daily brief (management insights)
|
|
1040
|
+
|
|
1041
|
+
```bash
|
|
1042
|
+
lumine admin brief --json
|
|
1043
|
+
lumine admin brief --days 3 --json
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
Read-only management insights for the delegated workflow, windowed since the
|
|
1047
|
+
operator's last completed run by default (`--days 1..30` overrides; capped at
|
|
1048
|
+
30 days). Call it early in every run — right after the newspaper check — and
|
|
1049
|
+
end every run report with an **"Insights for Mikey"** section carrying only
|
|
1050
|
+
the deltas and anomalies worth his time, next to the escalation list. Never
|
|
1051
|
+
dump raw sections at him.
|
|
1052
|
+
|
|
1053
|
+
Four sections (Mikey's chosen cut, 2026-08-10):
|
|
1054
|
+
|
|
1055
|
+
- `economy` — `topGainers` (coin-ledger aggregation over the window: gained,
|
|
1056
|
+
spent, net, current balance per user, Zero/Ciel excluded) and `topBalances`
|
|
1057
|
+
(current top-ten holders, also excluding Zero/Ciel). This is where
|
|
1058
|
+
alt-account farming, sudden windfalls, and "someone got a million coins in a
|
|
1059
|
+
week" surface with real numbers instead of secondhand kid gossip;
|
|
1060
|
+
cross-check outliers against the
|
|
1061
|
+
economy-manipulation escalation category.
|
|
1062
|
+
- `aiSpending` — a compact projection of the management AI-cost report:
|
|
1063
|
+
`summary`, top spending accounts, top risk groups. Unlike the other sections'
|
|
1064
|
+
exact `window.sinceTs`, this existing report is bucketed into whole UTC days;
|
|
1065
|
+
`aiSpending.days` includes today and may begin up to one day before the exact
|
|
1066
|
+
brief window. Flag accounts that jumped tiers or dominate that report period.
|
|
1067
|
+
May be `{ unavailable: true }` if the cost report fails; say so rather than
|
|
1068
|
+
guessing.
|
|
1069
|
+
- `notableCandidates` — kids (never bots, staff `userType`s, or users already
|
|
1070
|
+
on the Notable Users list) ranked by authored activity in the window, with
|
|
1071
|
+
`isNewUser` marking window-new signups. Use it to find the overlooked and
|
|
1072
|
+
rising users the editorial priorities exist for, and propose additions to
|
|
1073
|
+
Mikey's Notable Users list in the report — the run never edits that list
|
|
1074
|
+
itself.
|
|
1075
|
+
- `teachers` — every `userType='supermod'` account with per-window
|
|
1076
|
+
`subjectsPosted`, `commentsPosted`, `recommendationsGiven`, `rewardsGiven`,
|
|
1077
|
+
`rewardTwinklesGiven`, `lastActive`/`daysSinceActive`, ordered by an
|
|
1078
|
+
engagement score. Mikey's standing question here: which teachers genuinely
|
|
1079
|
+
use the website to its fullest and which only work through it. Authored
|
|
1080
|
+
posts and comments signal personal engagement; recommendations and rewards
|
|
1081
|
+
are the "work" verbs — report the contrast, not just the totals, and treat
|
|
1082
|
+
it as notable-users-but-for-teachers.
|
|
1083
|
+
|
|
1084
|
+
The command needs only an active run's `content:read` scope and mutates
|
|
1085
|
+
nothing; reading the brief is not audited content action. Window boundaries
|
|
1086
|
+
on the big append-only ledgers are found by binary-searching the PRIMARY key
|
|
1087
|
+
(several tables have no timeStamp index), avoiding lifetime scans; aggregation
|
|
1088
|
+
is still bounded to the selected 1–30 day window.
|
|
1089
|
+
|
|
1090
|
+
```ts
|
|
1091
|
+
type InsightsBrief = Success<{
|
|
1092
|
+
window: {
|
|
1093
|
+
sinceTs: number;
|
|
1094
|
+
days: number;
|
|
1095
|
+
source: 'requested' | 'since-last-completed-run' | 'default';
|
|
1096
|
+
generatedAt: number;
|
|
1097
|
+
};
|
|
1098
|
+
economy: {
|
|
1099
|
+
topGainers: Array<{
|
|
1100
|
+
userId: number;
|
|
1101
|
+
username: string | null;
|
|
1102
|
+
userType: string | null;
|
|
1103
|
+
gained: number;
|
|
1104
|
+
spent: number;
|
|
1105
|
+
net: number;
|
|
1106
|
+
currentCoins: number;
|
|
1107
|
+
joinedAt: number | null;
|
|
1108
|
+
}>;
|
|
1109
|
+
topBalances: Array<{
|
|
1110
|
+
userId: number;
|
|
1111
|
+
username: string | null;
|
|
1112
|
+
userType: string | null;
|
|
1113
|
+
coins: number;
|
|
1114
|
+
}>;
|
|
1115
|
+
};
|
|
1116
|
+
notableCandidates: Array<{
|
|
1117
|
+
userId: number;
|
|
1118
|
+
username: string | null;
|
|
1119
|
+
subjectsPosted: number;
|
|
1120
|
+
commentsPosted: number;
|
|
1121
|
+
activityScore: number;
|
|
1122
|
+
joinedAt: number | null;
|
|
1123
|
+
isNewUser: boolean;
|
|
1124
|
+
lastActive: number | null;
|
|
1125
|
+
}>;
|
|
1126
|
+
teachers: Array<{
|
|
1127
|
+
userId: number;
|
|
1128
|
+
username: string | null;
|
|
1129
|
+
lastActive: number | null;
|
|
1130
|
+
daysSinceActive: number | null;
|
|
1131
|
+
subjectsPosted: number;
|
|
1132
|
+
commentsPosted: number;
|
|
1133
|
+
recommendationsGiven: number;
|
|
1134
|
+
rewardsGiven: number;
|
|
1135
|
+
rewardTwinklesGiven: number;
|
|
1136
|
+
engagementScore: number;
|
|
1137
|
+
}>;
|
|
1138
|
+
aiSpending:
|
|
1139
|
+
| {
|
|
1140
|
+
days: number;
|
|
1141
|
+
summary: unknown;
|
|
1142
|
+
topAccounts: unknown[];
|
|
1143
|
+
topRiskGroups: unknown[];
|
|
1144
|
+
}
|
|
1145
|
+
| { unavailable: true; error: string };
|
|
1146
|
+
}>;
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1030
1149
|
## Audit history
|
|
1031
1150
|
|
|
1032
1151
|
```bash
|
|
@@ -1082,19 +1201,57 @@ type AuditList = Success<{
|
|
|
1082
1201
|
## Persona-backed comments and replies
|
|
1083
1202
|
|
|
1084
1203
|
```bash
|
|
1085
|
-
lumine admin daily-run start --identity auto --comment-mode draft --json
|
|
1086
|
-
lumine admin comment draft 123 --identity auto --json
|
|
1087
|
-
|
|
1088
1204
|
lumine admin daily-run start --identity ciel --comment-mode post \
|
|
1089
1205
|
--run-key daily:2026-08-06:comments --json
|
|
1206
|
+
|
|
1207
|
+
# Default: the agent composes the comment in the bot's persona itself.
|
|
1208
|
+
lumine admin comment draft 123 --file comment.md --json
|
|
1209
|
+
lumine admin comment draft dailyReflection:99 --file comment.md --json
|
|
1210
|
+
lumine admin comment reply comment:456 --file reply.md --json
|
|
1211
|
+
|
|
1212
|
+
# Fallback (only when Mikey asks for it): server-generated persona drafts.
|
|
1090
1213
|
lumine admin comment draft 123 --identity ciel \
|
|
1091
1214
|
--idempotency-key comment-123-draft-v1 --json
|
|
1092
|
-
lumine admin comment draft dailyReflection:99 --json
|
|
1093
1215
|
lumine admin comment reply comment:456 --json
|
|
1216
|
+
|
|
1094
1217
|
lumine admin comment post --draft-id 77 \
|
|
1095
1218
|
--idempotency-key comment-123-post-v1 --json
|
|
1096
1219
|
```
|
|
1097
1220
|
|
|
1221
|
+
**Compose in persona by default (Mikey's standing direction, 2026-08-10).**
|
|
1222
|
+
A delegated agent writing Zero/Ciel comments should assume the bot's persona
|
|
1223
|
+
and write the comment text itself, submitting it with `--file` — exactly like
|
|
1224
|
+
the newspaper's claim/submit path, this spends no provider credits and no AI
|
|
1225
|
+
Energy (server-generated drafts bill the **operator's own** AI Energy
|
|
1226
|
+
battery). Use the no-`--file` server-generated path only when Mikey
|
|
1227
|
+
explicitly asks for it. Before composing, read the canonical persona sources
|
|
1228
|
+
so the voice and judgment match the real bots — do not improvise the persona
|
|
1229
|
+
from memory:
|
|
1230
|
+
|
|
1231
|
+
- `twinkle-api/constants/index.ts` — `SYS_PROMPT_FOR_CIEL` /
|
|
1232
|
+
`SYS_PROMPT_FOR_ZERO` (the exact persona system prompts) and
|
|
1233
|
+
`TWINKLE_FEATURES_EXPLANATION` (what the bots know about the site);
|
|
1234
|
+
- `twinkle-api/helpers/ai/comment-assistant/index.ts` —
|
|
1235
|
+
`ADMIN_COMMENT_DECISION_POLICY` / `ADMIN_REPLY_DECISION_POLICY` (the
|
|
1236
|
+
draft-vs-skip judgment rules, which still govern composed comments: skip
|
|
1237
|
+
decisions are yours to make and record with `post skip` or in the run
|
|
1238
|
+
report).
|
|
1239
|
+
|
|
1240
|
+
A composed draft (`--file`, plain UTF-8 text, at most the website's 10,000
|
|
1241
|
+
character comment limit) flows through the identical draft lifecycle —
|
|
1242
|
+
reservation, idempotency, context-revision CAS, publish fencing, audit
|
|
1243
|
+
(`metadata.composed: true`) — and is published with the same
|
|
1244
|
+
`comment post --draft-id`. It never invokes the server's model and records
|
|
1245
|
+
no AI Energy usage. Deployment guard: an API deployed before this capability
|
|
1246
|
+
silently ignores `content` and generates with the server's model instead. The
|
|
1247
|
+
CLI therefore requires the ready draft response to echo the exact submitted
|
|
1248
|
+
text with `reason: "operator-composed"`; otherwise it stops with
|
|
1249
|
+
`LUMINE_ADMIN_COMPOSED_COMMENT_UNSUPPORTED`. Never publish that rejected draft.
|
|
1250
|
+
Placement stays on the requested target: compose replies via `comment:<id>`
|
|
1251
|
+
targets (the generated path's model-chosen
|
|
1252
|
+
`replyTargetCommentId` does not apply). Everything below about targets,
|
|
1253
|
+
containers, and publication applies to both kinds of draft.
|
|
1254
|
+
|
|
1098
1255
|
A draft targets one of:
|
|
1099
1256
|
|
|
1100
1257
|
- `subject:<id>` (or a bare numeric ID) — a top-level comment on the subject;
|