@stage5/lumine 0.2.24 → 0.2.25

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/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"],
@@ -551,6 +619,9 @@ export function parseAdminOperation(options) {
551
619
  identity: options.adminIdentity
552
620
  ? parseIdentity(options.adminIdentity)
553
621
  : undefined,
622
+ ...(options.adminFile
623
+ ? { content: readComposedCommentFile(options.adminFile) }
624
+ : {}),
554
625
  },
555
626
  );
556
627
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
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
@@ -1082,19 +1091,57 @@ type AuditList = Success<{
1082
1091
  ## Persona-backed comments and replies
1083
1092
 
1084
1093
  ```bash
1085
- lumine admin daily-run start --identity auto --comment-mode draft --json
1086
- lumine admin comment draft 123 --identity auto --json
1087
-
1088
1094
  lumine admin daily-run start --identity ciel --comment-mode post \
1089
1095
  --run-key daily:2026-08-06:comments --json
1096
+
1097
+ # Default: the agent composes the comment in the bot's persona itself.
1098
+ lumine admin comment draft 123 --file comment.md --json
1099
+ lumine admin comment draft dailyReflection:99 --file comment.md --json
1100
+ lumine admin comment reply comment:456 --file reply.md --json
1101
+
1102
+ # Fallback (only when Mikey asks for it): server-generated persona drafts.
1090
1103
  lumine admin comment draft 123 --identity ciel \
1091
1104
  --idempotency-key comment-123-draft-v1 --json
1092
- lumine admin comment draft dailyReflection:99 --json
1093
1105
  lumine admin comment reply comment:456 --json
1106
+
1094
1107
  lumine admin comment post --draft-id 77 \
1095
1108
  --idempotency-key comment-123-post-v1 --json
1096
1109
  ```
1097
1110
 
1111
+ **Compose in persona by default (Mikey's standing direction, 2026-08-10).**
1112
+ A delegated agent writing Zero/Ciel comments should assume the bot's persona
1113
+ and write the comment text itself, submitting it with `--file` — exactly like
1114
+ the newspaper's claim/submit path, this spends no provider credits and no AI
1115
+ Energy (server-generated drafts bill the **operator's own** AI Energy
1116
+ battery). Use the no-`--file` server-generated path only when Mikey
1117
+ explicitly asks for it. Before composing, read the canonical persona sources
1118
+ so the voice and judgment match the real bots — do not improvise the persona
1119
+ from memory:
1120
+
1121
+ - `twinkle-api/constants/index.ts` — `SYS_PROMPT_FOR_CIEL` /
1122
+ `SYS_PROMPT_FOR_ZERO` (the exact persona system prompts) and
1123
+ `TWINKLE_FEATURES_EXPLANATION` (what the bots know about the site);
1124
+ - `twinkle-api/helpers/ai/comment-assistant/index.ts` —
1125
+ `ADMIN_COMMENT_DECISION_POLICY` / `ADMIN_REPLY_DECISION_POLICY` (the
1126
+ draft-vs-skip judgment rules, which still govern composed comments: skip
1127
+ decisions are yours to make and record with `post skip` or in the run
1128
+ report).
1129
+
1130
+ A composed draft (`--file`, plain UTF-8 text, at most the website's 10,000
1131
+ character comment limit) flows through the identical draft lifecycle —
1132
+ reservation, idempotency, context-revision CAS, publish fencing, audit
1133
+ (`metadata.composed: true`) — and is published with the same
1134
+ `comment post --draft-id`. It never invokes the server's model and records
1135
+ no AI Energy usage. Deployment guard: an API deployed before this capability
1136
+ silently ignores `content` and generates with the server's model instead. The
1137
+ CLI therefore requires the ready draft response to echo the exact submitted
1138
+ text with `reason: "operator-composed"`; otherwise it stops with
1139
+ `LUMINE_ADMIN_COMPOSED_COMMENT_UNSUPPORTED`. Never publish that rejected draft.
1140
+ Placement stays on the requested target: compose replies via `comment:<id>`
1141
+ targets (the generated path's model-chosen
1142
+ `replyTargetCommentId` does not apply). Everything below about targets,
1143
+ containers, and publication applies to both kinds of draft.
1144
+
1098
1145
  A draft targets one of:
1099
1146
 
1100
1147
  - `subject:<id>` (or a bare numeric ID) — a top-level comment on the subject;