@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;