@stage5/lumine 0.2.19 → 0.2.21
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 +234 -5
- package/lib/commands.js +15 -2
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +375 -44
package/lib/admin.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
3
4
|
import { requestJson } from "./http.js";
|
|
4
5
|
|
|
6
|
+
const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
|
|
7
|
+
|
|
8
|
+
function readEditorialFile(filePath) {
|
|
9
|
+
const normalizedPath = String(filePath || "").trim();
|
|
10
|
+
if (!normalizedPath) {
|
|
11
|
+
throw cliValidationError(
|
|
12
|
+
"Pass the editorial JSON with --file <editorial.json>.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
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_EDITORIAL_FILE_BYTES) {
|
|
22
|
+
throw cliValidationError("The editorial file must be under 256KB.");
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(contents);
|
|
26
|
+
} catch {
|
|
27
|
+
throw cliValidationError(`${normalizedPath} is not valid JSON.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
export async function adminCommand(options) {
|
|
6
32
|
const operation = parseAdminOperation(options);
|
|
33
|
+
const recommendationContentTypes =
|
|
34
|
+
operation.name === "recommendations.list"
|
|
35
|
+
? parseRecommendationContentTypes(options.adminContentTypes)
|
|
36
|
+
: null;
|
|
7
37
|
const auth = await resolveAuth(options);
|
|
8
38
|
await assertAuthScope({
|
|
9
39
|
options,
|
|
@@ -54,6 +84,12 @@ export async function adminCommand(options) {
|
|
|
54
84
|
},
|
|
55
85
|
timeoutMs: options.timeoutMs,
|
|
56
86
|
});
|
|
87
|
+
if (recommendationContentTypes) {
|
|
88
|
+
result = filterRecommendationQueueResult({
|
|
89
|
+
result,
|
|
90
|
+
contentTypes: recommendationContentTypes,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
57
93
|
} catch (error) {
|
|
58
94
|
if (operation.mutates && requestId) {
|
|
59
95
|
const retryInstruction = `Retry with --idempotency-key ${requestId}.`;
|
|
@@ -90,6 +126,49 @@ export async function adminCommand(options) {
|
|
|
90
126
|
return result;
|
|
91
127
|
}
|
|
92
128
|
|
|
129
|
+
const RECOMMENDATION_CONTENT_TYPES = new Map([
|
|
130
|
+
["comment", "comment"],
|
|
131
|
+
["aistory", "aiStory"],
|
|
132
|
+
["dailyreflection", "dailyReflection"],
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
export function parseRecommendationContentTypes(value) {
|
|
136
|
+
const raw = String(value || "").trim();
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
const contentTypes = raw
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((item) => item.trim())
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((item) => RECOMMENDATION_CONTENT_TYPES.get(item.toLowerCase()));
|
|
143
|
+
if (
|
|
144
|
+
contentTypes.length === 0 ||
|
|
145
|
+
contentTypes.some((contentType) => !contentType) ||
|
|
146
|
+
new Set(contentTypes).size !== contentTypes.length
|
|
147
|
+
) {
|
|
148
|
+
throw cliValidationError(
|
|
149
|
+
"--content-types accepts comment, aiStory, and dailyReflection.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return [...new Set(contentTypes)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function filterRecommendationQueueResult({ result, contentTypes }) {
|
|
156
|
+
const items = Array.isArray(result?.data?.items) ? result.data.items : [];
|
|
157
|
+
const allowed = new Set(contentTypes);
|
|
158
|
+
const filteredItems = items.filter((item) => allowed.has(item?.contentType));
|
|
159
|
+
return {
|
|
160
|
+
...result,
|
|
161
|
+
data: {
|
|
162
|
+
...result.data,
|
|
163
|
+
items: filteredItems,
|
|
164
|
+
clientFilter: {
|
|
165
|
+
contentTypes,
|
|
166
|
+
excludedItems: items.length - filteredItems.length,
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
93
172
|
function adminOperationRequiresRun(operation) {
|
|
94
173
|
return ![
|
|
95
174
|
"identity.list",
|
|
@@ -178,6 +257,7 @@ export function parseAdminOperation(options) {
|
|
|
178
257
|
"recommendations.list",
|
|
179
258
|
withQuery("/cli/admin/recommendations", {
|
|
180
259
|
kind,
|
|
260
|
+
contentTypes: options.adminContentTypes,
|
|
181
261
|
cursor: options.adminCursor,
|
|
182
262
|
limit: options.limit,
|
|
183
263
|
}),
|
|
@@ -279,6 +359,23 @@ export function parseAdminOperation(options) {
|
|
|
279
359
|
if (action === "recommend") {
|
|
280
360
|
return recommendOperation(target, options);
|
|
281
361
|
}
|
|
362
|
+
if (action === "skip") {
|
|
363
|
+
const parsedTarget = parseRecommendationTarget({
|
|
364
|
+
target,
|
|
365
|
+
explicitType: options.adminType,
|
|
366
|
+
});
|
|
367
|
+
if (parsedTarget.type === "subject") {
|
|
368
|
+
throw cliValidationError(
|
|
369
|
+
"Skips apply to comment, aiStory, and dailyReflection targets; subjects leave the queue through effort assignment.",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return writeOperation(
|
|
373
|
+
"post.skip",
|
|
374
|
+
"POST",
|
|
375
|
+
`/cli/admin/skips/${parsedTarget.type}/${parsedTarget.id}`,
|
|
376
|
+
{ reason: options.adminReason || undefined },
|
|
377
|
+
);
|
|
378
|
+
}
|
|
282
379
|
if (action === "reward") {
|
|
283
380
|
const parsedTarget = parseRecommendationTarget({
|
|
284
381
|
target,
|
|
@@ -303,15 +400,87 @@ export function parseAdminOperation(options) {
|
|
|
303
400
|
return recommendOperation(action, options);
|
|
304
401
|
}
|
|
305
402
|
|
|
403
|
+
if (namespace === "news") {
|
|
404
|
+
if (!action || action === "status") {
|
|
405
|
+
return readOperation("news.status", "/cli/admin/news");
|
|
406
|
+
}
|
|
407
|
+
if (action === "print") {
|
|
408
|
+
return writeOperation("news.print", "POST", "/cli/admin/news/print", {});
|
|
409
|
+
}
|
|
410
|
+
if (action === "claim") {
|
|
411
|
+
const repairDate = String(options.adminDate || "").trim();
|
|
412
|
+
if (repairDate && !/^\d{4}-\d{2}-\d{2}$/.test(repairDate)) {
|
|
413
|
+
throw cliValidationError("--date must be YYYY-MM-DD.");
|
|
414
|
+
}
|
|
415
|
+
return writeOperation("news.claim", "POST", "/cli/admin/news/claim", {
|
|
416
|
+
...(repairDate ? { date: repairDate } : {}),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (action === "submit") {
|
|
420
|
+
const editionId = parseRequiredInteger(
|
|
421
|
+
options.adminEditionId,
|
|
422
|
+
"--edition-id",
|
|
423
|
+
1,
|
|
424
|
+
);
|
|
425
|
+
const leaseToken = String(options.adminLeaseToken || "").trim();
|
|
426
|
+
if (!leaseToken) {
|
|
427
|
+
throw cliValidationError(
|
|
428
|
+
"Pass the claim's lease token with --lease-token <token>.",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return writeOperation(
|
|
432
|
+
"news.submit",
|
|
433
|
+
"POST",
|
|
434
|
+
"/cli/admin/news/submit",
|
|
435
|
+
{
|
|
436
|
+
editionId,
|
|
437
|
+
leaseToken,
|
|
438
|
+
editorial: readEditorialFile(options.adminFile),
|
|
439
|
+
model: options.model || undefined,
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
throw cliValidationError(
|
|
444
|
+
"Usage: lumine admin news [status] | news print | news claim | news submit --edition-id <id> --lease-token <token> --file <editorial.json>",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (namespace === "audit" && (!action || action === "list")) {
|
|
449
|
+
const runFilter = String(options.adminRun || "").trim();
|
|
450
|
+
if (runFilter && !["current", "last"].includes(runFilter)) {
|
|
451
|
+
parseRequiredInteger(runFilter, "--run", 1);
|
|
452
|
+
}
|
|
453
|
+
return readOperation(
|
|
454
|
+
"audit.list",
|
|
455
|
+
withQuery("/cli/admin/audit", {
|
|
456
|
+
run: runFilter,
|
|
457
|
+
target: options.adminTarget,
|
|
458
|
+
actions: options.adminActions,
|
|
459
|
+
cursor: options.adminCursor,
|
|
460
|
+
limit: options.limit,
|
|
461
|
+
full: options.adminFull ? "true" : "",
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
306
466
|
if (namespace === "comment") {
|
|
307
|
-
if (action === "draft") {
|
|
308
|
-
const
|
|
467
|
+
if (action === "draft" || action === "reply") {
|
|
468
|
+
const parsedTarget = parseRecommendationTarget({
|
|
469
|
+
target,
|
|
470
|
+
explicitType: options.adminType,
|
|
471
|
+
});
|
|
472
|
+
if (action === "reply" && parsedTarget.type !== "comment") {
|
|
473
|
+
throw cliValidationError(
|
|
474
|
+
"comment reply targets a comment: lumine admin comment reply comment:<id>.",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
309
477
|
return writeOperation(
|
|
310
478
|
"comment.draft",
|
|
311
479
|
"POST",
|
|
312
480
|
"/cli/admin/comment-drafts",
|
|
313
481
|
{
|
|
314
|
-
|
|
482
|
+
targetType: parsedTarget.type,
|
|
483
|
+
targetId: parsedTarget.id,
|
|
315
484
|
identity: options.adminIdentity
|
|
316
485
|
? parseIdentity(options.adminIdentity)
|
|
317
486
|
: undefined,
|
|
@@ -334,7 +503,7 @@ export function parseAdminOperation(options) {
|
|
|
334
503
|
}
|
|
335
504
|
|
|
336
505
|
throw cliValidationError(
|
|
337
|
-
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment ...",
|
|
506
|
+
"Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment|news|audit ...",
|
|
338
507
|
);
|
|
339
508
|
}
|
|
340
509
|
|
|
@@ -687,6 +856,65 @@ function printAdminResult({ operation, result }) {
|
|
|
687
856
|
printPagination(data.pagination);
|
|
688
857
|
return;
|
|
689
858
|
}
|
|
859
|
+
if (Array.isArray(data.events)) {
|
|
860
|
+
console.log(`${data.events.length} audit event(s):`);
|
|
861
|
+
for (const event of data.events) {
|
|
862
|
+
const target =
|
|
863
|
+
event.targetType && event.targetId
|
|
864
|
+
? ` ${event.targetType}:${event.targetId}`
|
|
865
|
+
: "";
|
|
866
|
+
console.log(
|
|
867
|
+
`#${event.id} run ${event.runId ?? "-"} ${event.action}${target} — ${event.result}${event.changed === true ? " (changed)" : event.changed === false ? " (no change)" : ""}`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
printPagination(data.pagination);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (data.claim) {
|
|
874
|
+
console.log(
|
|
875
|
+
`Claimed edition #${data.claim.editionId} (${data.claim.dateKey}): ${data.claim.events.length} event(s); lease token ${data.claim.leaseToken}.`,
|
|
876
|
+
);
|
|
877
|
+
console.log(
|
|
878
|
+
`Write the editorial JSON, then run: lumine admin news submit --edition-id ${data.claim.editionId} --lease-token ${data.claim.leaseToken} --file editorial.json`,
|
|
879
|
+
);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (data.newspaper) {
|
|
883
|
+
const paper = data.newspaper;
|
|
884
|
+
if (paper.printedToday) {
|
|
885
|
+
const printed = paper.latestPrinted || {};
|
|
886
|
+
console.log(
|
|
887
|
+
`Newspaper ${paper.dateKey}: printed (revision ${printed.revisionNumber || 1}, ${printed.sourceEventCount ?? 0} sources).`,
|
|
888
|
+
);
|
|
889
|
+
} else {
|
|
890
|
+
console.log(
|
|
891
|
+
`Newspaper ${paper.dateKey}: not printed (${paper.generationStatus}).`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
if (paper.requestedAction && paper.requestedAction !== "none") {
|
|
895
|
+
console.log(
|
|
896
|
+
`${paper.requestedAction === "retry" ? "Queued a retry of" : "Queued"} today's edition; the press typesets it within about a minute. Re-check with: lumine admin news`,
|
|
897
|
+
);
|
|
898
|
+
} else if (
|
|
899
|
+
!paper.printedToday &&
|
|
900
|
+
["pending", "generating"].includes(paper.generationStatus)
|
|
901
|
+
) {
|
|
902
|
+
console.log(
|
|
903
|
+
"An edition is being typeset now. Re-check with: lumine admin news",
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
if (paper.failureMessage && !paper.printedToday) {
|
|
907
|
+
console.log(`Last attempt failed: ${paper.failureMessage}`);
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (data.skip) {
|
|
912
|
+
console.log(
|
|
913
|
+
`${result.status}: ${data.skip.contentType}:${data.skip.contentId} skipped.`,
|
|
914
|
+
);
|
|
915
|
+
if (data.skip.url) console.log(data.skip.url);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
690
918
|
if (Array.isArray(data.items)) {
|
|
691
919
|
console.log(`${data.items.length} recommendation candidate(s):`);
|
|
692
920
|
for (const item of data.items) {
|
|
@@ -719,7 +947,8 @@ function printAdminResult({ operation, result }) {
|
|
|
719
947
|
`Draft #${data.draft.id}: ${data.draft.decision} (${data.draft.status}).`,
|
|
720
948
|
);
|
|
721
949
|
if (data.draft.content) console.log(data.draft.content);
|
|
722
|
-
|
|
950
|
+
const draftUrl = data.draft.targetUrl || data.draft.subjectUrl;
|
|
951
|
+
if (draftUrl) console.log(draftUrl);
|
|
723
952
|
return;
|
|
724
953
|
}
|
|
725
954
|
if (data.subject) {
|
package/lib/commands.js
CHANGED
|
@@ -2096,6 +2096,7 @@ export function parseArgs(args) {
|
|
|
2096
2096
|
"noBrowser",
|
|
2097
2097
|
"includeComments",
|
|
2098
2098
|
"anyoneCanReward",
|
|
2099
|
+
"full",
|
|
2099
2100
|
]);
|
|
2100
2101
|
|
|
2101
2102
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -2175,6 +2176,7 @@ export function parseArgs(args) {
|
|
|
2175
2176
|
: "",
|
|
2176
2177
|
adminType: raw.type ? String(raw.type) : "",
|
|
2177
2178
|
adminKind: raw.kind ? String(raw.kind) : "",
|
|
2179
|
+
adminContentTypes: raw.contentTypes ? String(raw.contentTypes) : "",
|
|
2178
2180
|
adminIdentity: raw.identity ? String(raw.identity) : "",
|
|
2179
2181
|
commentMode: raw.commentMode ? String(raw.commentMode) : "",
|
|
2180
2182
|
runKey: raw.runKey ? String(raw.runKey) : "",
|
|
@@ -2182,6 +2184,14 @@ export function parseArgs(args) {
|
|
|
2182
2184
|
draftId: raw.draftId ? String(raw.draftId) : "",
|
|
2183
2185
|
twinkles: raw.twinkles ? String(raw.twinkles) : "",
|
|
2184
2186
|
adminReason: raw.reason ? String(raw.reason) : "",
|
|
2187
|
+
adminRun: raw.run ? String(raw.run) : "",
|
|
2188
|
+
adminTarget: raw.target ? String(raw.target) : "",
|
|
2189
|
+
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2190
|
+
adminDate: raw.date ? String(raw.date) : "",
|
|
2191
|
+
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
2192
|
+
adminLeaseToken: raw.leaseToken ? String(raw.leaseToken) : "",
|
|
2193
|
+
adminFile: raw.file ? String(raw.file) : "",
|
|
2194
|
+
adminFull: parseBoolean(raw.full, false),
|
|
2185
2195
|
rewardTwinkles: raw.rewardTwinkles ? String(raw.rewardTwinkles) : "",
|
|
2186
2196
|
includeComments: parseBoolean(raw.includeComments, false),
|
|
2187
2197
|
anyoneCanReward: parseBoolean(raw.anyoneCanReward, false),
|
|
@@ -2500,7 +2510,7 @@ export function printHelp() {
|
|
|
2500
2510
|
lumine admin identity list|status|use <zero|ciel|auto> [--json]
|
|
2501
2511
|
lumine admin daily-run start [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
|
|
2502
2512
|
lumine admin daily-run status|complete|fail [--reason <text>] [--json]
|
|
2503
|
-
lumine admin recommendations list [--cursor <cursor>] [--json]
|
|
2513
|
+
lumine admin recommendations list [--content-types comment,dailyReflection] [--cursor <cursor>] [--json]
|
|
2504
2514
|
lumine admin subjects candidates [--after <date>] [--effort unassigned] [--cursor <cursor>] [--json]
|
|
2505
2515
|
lumine admin subject get|comments|reveal <subject-url-or-id> [--cursor <cursor>] [--json]
|
|
2506
2516
|
lumine admin subject effort set <subject-id> --level <1|2|3> [--json]
|
|
@@ -2509,9 +2519,12 @@ export function printHelp() {
|
|
|
2509
2519
|
lumine admin featured list|reorder --subject-ids <id,id,...> [--json]
|
|
2510
2520
|
lumine admin post get|comments <target> [--type subject|comment|aiStory|dailyReflection] [--cursor <cursor>] [--json]
|
|
2511
2521
|
lumine admin post recommend <target> [--type subject|comment|aiStory|dailyReflection] [--anyone-can-reward] [--reward-twinkles 3] [--json]
|
|
2522
|
+
lumine admin post skip <target> [--type comment|aiStory|dailyReflection] [--reason <text>] [--json]
|
|
2512
2523
|
lumine admin post reward <target> [--type subject|comment|aiStory|dailyReflection] --twinkles 3 [--json]
|
|
2513
|
-
lumine admin comment draft <
|
|
2524
|
+
lumine admin comment draft <target> [--type subject|comment|aiStory|dailyReflection] [--identity zero|ciel|auto] [--json]
|
|
2525
|
+
lumine admin comment reply comment:<id> [--identity zero|ciel|auto] [--json]
|
|
2514
2526
|
lumine admin comment post --draft-id <id> [--json]
|
|
2527
|
+
lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--cursor <cursor>] [--json]
|
|
2515
2528
|
|
|
2516
2529
|
Examples:
|
|
2517
2530
|
npx @stage5/lumine@latest
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -273,9 +273,26 @@ type DailyRunFail = DailyRunComplete;
|
|
|
273
273
|
|
|
274
274
|
`lastRun` makes a lost-response retry of `complete` or `fail` possible after
|
|
275
275
|
the active pointer has been cleared. Other run-scoped commands accept only the
|
|
276
|
-
current unexpired `active` run. Completion
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
current unexpired `active` run. Completion first finalizes any mutation whose
|
|
277
|
+
content change committed but whose audit bookkeeping was still pending,
|
|
278
|
+
counting it toward the run's rotation signal. It then rejects only while a
|
|
279
|
+
mutation from the last ten minutes is genuinely in flight (the 409 lists the
|
|
280
|
+
pending mutations and a `retryAfterSeconds`); older in-flight rows are treated
|
|
281
|
+
as orphans of a dead process and no longer block completion. `fail` remains
|
|
282
|
+
available to abandon a run without advancing rotation, including a run whose
|
|
283
|
+
six-hour authorization has expired; an expired run can never be completed.
|
|
284
|
+
A TTL-expired run is reported with status `expired` even before the next
|
|
285
|
+
start reaps it, so `daily-run status` never shows an unusable run as
|
|
286
|
+
`active`.
|
|
287
|
+
|
|
288
|
+
Starting with a run key that belongs to a finished or expired run fails with
|
|
289
|
+
`CLI_ADMIN_RUN_KEY_ALREADY_USED`; supply a fresh `--run-key` (for example
|
|
290
|
+
`daily:2026-08-07:2`) to start again the same day. Reusing the key of the
|
|
291
|
+
live active run returns that run only when the requested `--comment-mode`
|
|
292
|
+
and any explicit `--identity` match it; otherwise the start fails with
|
|
293
|
+
`CLI_ADMIN_RUN_SETTINGS_MISMATCH` instead of silently returning a run with
|
|
294
|
+
different scopes. The same check applies when a start without the active
|
|
295
|
+
run's key would fall back to that active run.
|
|
279
296
|
|
|
280
297
|
The default run key is `daily:YYYY-MM-DD` in Asia/Bangkok. Supply `--run-key`
|
|
281
298
|
for a separate explicit run. `--idempotency-key` may be supplied to any
|
|
@@ -286,7 +303,8 @@ JSON error includes `details.retryIdempotencyKey` for a safe exact retry.
|
|
|
286
303
|
## Canonical lists and inspection
|
|
287
304
|
|
|
288
305
|
```bash
|
|
289
|
-
lumine admin recommendations list --kind recommend
|
|
306
|
+
lumine admin recommendations list --kind recommend \
|
|
307
|
+
--content-types comment,dailyReflection --cursor '<cursor>' --json
|
|
290
308
|
lumine admin subjects candidates --after 2026-08-01T00:00:00Z \
|
|
291
309
|
--cursor '<cursor>' --json
|
|
292
310
|
lumine admin subjects candidates --effort unassigned --json
|
|
@@ -321,20 +339,37 @@ type RecommendationQueueList = Success<{
|
|
|
321
339
|
recommendation: RecommendationState;
|
|
322
340
|
reward: RewardState;
|
|
323
341
|
}>;
|
|
324
|
-
pagination: Pagination & {
|
|
342
|
+
pagination: Pagination & {
|
|
343
|
+
scannedCount: number;
|
|
344
|
+
contentTypes?: Array<"comment" | "aiStory" | "dailyReflection">;
|
|
345
|
+
};
|
|
346
|
+
clientFilter?: {
|
|
347
|
+
contentTypes: Array<"comment" | "aiStory" | "dailyReflection">;
|
|
348
|
+
excludedItems: number;
|
|
349
|
+
};
|
|
325
350
|
}>;
|
|
326
351
|
|
|
327
352
|
type SubjectCandidates = Success<{
|
|
328
353
|
subjects: Subject[];
|
|
329
|
-
pagination: Pagination;
|
|
354
|
+
pagination: Pagination & { scannedCount: number };
|
|
330
355
|
}>;
|
|
331
356
|
```
|
|
332
357
|
|
|
333
358
|
Both cursors freeze a primary-key high-water mark and traverse descending IDs,
|
|
334
|
-
so concurrent inserts cannot shift or duplicate later pages.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
359
|
+
so concurrent inserts cannot shift or duplicate later pages. Both walks scan a
|
|
360
|
+
bounded primary-key window (500 rows) per call before applying their residual
|
|
361
|
+
filters, so a page — recommendation or subject — can be empty while `hasMore`
|
|
362
|
+
remains true; continue until `exhausted`. Subject `--after` is inclusive, and
|
|
363
|
+
the opaque cursor is bound to its original date and effort filters.
|
|
364
|
+
|
|
365
|
+
`--content-types` is sent to APIs that support server-side filtering so excluded
|
|
366
|
+
types do not run their eligibility/content queries. The local CLI also filters
|
|
367
|
+
the returned page defensively for deployment compatibility. The server cursor
|
|
368
|
+
still advances across every underlying feed row, so excluding `aiStory` cannot
|
|
369
|
+
create gaps in later comment or Daily Reflection pages. New server cursors bind
|
|
370
|
+
the canonical content-type set; one legacy unbound cursor can be resumed and is
|
|
371
|
+
then reissued as bound. `clientFilter.excludedItems` makes any client-side
|
|
372
|
+
filtering explicit in JSON output.
|
|
338
373
|
|
|
339
374
|
For a run-scoped command, `--identity zero|ciel` is an assertion against the
|
|
340
375
|
server-selected run identity; it cannot switch actors locally. A mismatch
|
|
@@ -343,9 +378,14 @@ selection.
|
|
|
343
378
|
|
|
344
379
|
### Query and index design
|
|
345
380
|
|
|
346
|
-
Subject and queue traversal are bounded primary-key walks; the
|
|
347
|
-
most 500 `
|
|
348
|
-
|
|
381
|
+
Subject and queue traversal are bounded primary-key walks; the subject walk
|
|
382
|
+
reads at most 500 `content_subjects` rows per cursor step, and the queue reads
|
|
383
|
+
at most 500 `noti_feeds` rows per cursor step before applying the existing Earn
|
|
384
|
+
Recommend eligibility predicates. The effort projection's
|
|
385
|
+
`UPDATE noti_feeds ... WHERE type = 'subject' AND contentId = ?` reuses the
|
|
386
|
+
website's canonical reward-level projection shape; deployment should verify
|
|
387
|
+
`noti_feeds` carries an index whose leading columns cover `(type, contentId)`
|
|
388
|
+
(or `(contentId, ...)`) as the canonical route already requires. The joins/`NOT EXISTS` checks are necessary
|
|
349
389
|
to preserve the normal recommendation and skip rules, but they run only for
|
|
350
390
|
IDs in that bounded window. Subject-comment traversal uses
|
|
351
391
|
`idx_comments_isDeleted_subject_id`; standalone-post comments use the existing
|
|
@@ -414,7 +454,13 @@ type SubjectGet = Success<{
|
|
|
414
454
|
answer: string | null;
|
|
415
455
|
attachment: unknown | null;
|
|
416
456
|
};
|
|
457
|
+
// True only when comments were actually returned; a secret-gated subject
|
|
458
|
+
// reports false here (with secret.shown false) even when they were
|
|
459
|
+
// requested.
|
|
417
460
|
commentsIncluded: boolean;
|
|
461
|
+
// The inline list is capped at 200 comments in conversation order; when
|
|
462
|
+
// true, page through `subject comments` for the rest.
|
|
463
|
+
commentsTruncated: boolean;
|
|
418
464
|
comments: Comment[];
|
|
419
465
|
};
|
|
420
466
|
}>;
|
|
@@ -503,7 +549,11 @@ type FeaturedReorder = SubjectFeature;
|
|
|
503
549
|
`reveal` publishes the existing hidden “viewed without responding” notification
|
|
504
550
|
as the selected bot, with the ordinary notification and socket side effects.
|
|
505
551
|
Effort assignment rejects an unrevealed secret subject. Creator attribution
|
|
506
|
-
requires an attachment.
|
|
552
|
+
requires an attachment. Both effort assignment and creator attribution enforce
|
|
553
|
+
the website's moderator-precedence rule: when a strictly higher-level
|
|
554
|
+
moderator recorded the current value, the mutation fails with
|
|
555
|
+
`CLI_ADMIN_MODERATOR_PRECEDENCE` (the bots compare at the shared canonical
|
|
556
|
+
effective level). Every response is reloaded from the writer.
|
|
507
557
|
|
|
508
558
|
Featured reorder is a complete-set replacement: it rejects duplicates,
|
|
509
559
|
unknown/deleted IDs, missing current members, non-subject rows, and more than
|
|
@@ -596,27 +646,263 @@ writer-locked state. A new approval reports the canonical 10-point contribution;
|
|
|
596
646
|
a retry reports zero newly awarded and the same confirmed absolute balance.
|
|
597
647
|
|
|
598
648
|
Recommendation history is checked across both management bots, so rotation
|
|
599
|
-
does not recommend the same target again
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
649
|
+
does not recommend the same target again — unless the request asks for
|
|
650
|
+
`--anyone-can-reward` and the other bot's recommendation does not carry that
|
|
651
|
+
permission, in which case the actor proceeds with its own recommendation so
|
|
652
|
+
the requested permission and paired reward are honored rather than silently
|
|
653
|
+
dropped. When the other bot's recommendation does satisfy the request, the
|
|
654
|
+
`managementBotDeduplication` payload is returned and any requested 3-Twinkle
|
|
655
|
+
reward is still processed. A bare recommend never downgrades an existing
|
|
656
|
+
recommendation's anyone-can-reward permission; only an explicit
|
|
657
|
+
`--anyone-can-reward` changes it, and only in the granting direction.
|
|
658
|
+
Changing only `anyoneCanReward` does not rerun prior-recommender approval.
|
|
659
|
+
Approval reward rows are locked and writer-read, so concurrent or restored
|
|
660
|
+
attempts cannot insert the same approval twice.
|
|
603
661
|
|
|
604
662
|
Both the standalone and combined reward paths also inspect existing 3-Twinkle
|
|
605
663
|
management rewards across Zero and Ciel. A canonical three from either bot is
|
|
606
664
|
reported as already rewarded instead of adding another management reward.
|
|
607
665
|
|
|
608
666
|
The separate 3-Twinkle reward targets the worthwhile canonical post or comment.
|
|
609
|
-
It uses the selected bot and Twinkle's ordinary canonical
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
667
|
+
It uses the selected bot and Twinkle's ordinary canonical Level and recipient
|
|
668
|
+
rules; Mikey is never charged while Zero/Ciel is displayed. Zero and Ciel are
|
|
669
|
+
exempt from recommendation and reward coin charges in the shared canonical
|
|
670
|
+
mutation helpers, so neither `insufficient_coins` nor a bot balance decrease
|
|
671
|
+
can occur for these actors; every human actor still pays under the existing
|
|
672
|
+
Level-based rules. The
|
|
613
673
|
reward transaction serializes the rewarder and cap-bearing content row, then
|
|
614
674
|
adds only the amount needed for that actor to total exactly three. Existing
|
|
615
675
|
three is `already_done`; a cap is `maximum_reached`.
|
|
616
676
|
If recommendation succeeds but reward fails, the command exits nonzero with
|
|
617
677
|
`partial_failure` and `retrySafe: true`.
|
|
618
678
|
|
|
619
|
-
##
|
|
679
|
+
## Skip decisions
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
lumine admin post skip dailyReflection:99 --json
|
|
683
|
+
lumine admin post skip comment:456 --reason "one-line answer, nothing to add" --json
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
A skip records that the management rotation has judged a recommend-queue item
|
|
687
|
+
and decided not to act, so neither bot's queue resurfaces it. It writes the
|
|
688
|
+
same canonical `users_earn_skip_status` row the website's Earn page writes
|
|
689
|
+
(`earnType 'karma'`, `action 'recommendation'`) under the acting bot, and the
|
|
690
|
+
queue eligibility predicates honor either bot's row. Human moderators' own
|
|
691
|
+
Earn queues are deliberately unaffected: the bots are not database supermods,
|
|
692
|
+
so a bot skip never hides content from a human, who may judge differently.
|
|
693
|
+
|
|
694
|
+
Targets are `comment`, `aiStory`, and `dailyReflection` only; subjects leave
|
|
695
|
+
their queue through effort assignment. Skipping an already-skipped item (by
|
|
696
|
+
either bot) returns `already_done` with `changed: false`. The optional
|
|
697
|
+
`--reason` (at most 500 characters) is stored in the private audit row's
|
|
698
|
+
metadata — it is the agent's memory of the judgment, not public content.
|
|
699
|
+
The skip requires the `recommendation:write` scope and is audited like every
|
|
700
|
+
other mutation.
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
type PostSkip = Success<{
|
|
704
|
+
skip: {
|
|
705
|
+
contentType: "comment" | "aiStory" | "dailyReflection";
|
|
706
|
+
contentId: number;
|
|
707
|
+
url: string;
|
|
708
|
+
skippedByUserId: number;
|
|
709
|
+
skippedAt: number | null;
|
|
710
|
+
};
|
|
711
|
+
}>;
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
## Twinkle Newspaper
|
|
715
|
+
|
|
716
|
+
```bash
|
|
717
|
+
lumine admin news --json
|
|
718
|
+
lumine admin news claim --json
|
|
719
|
+
lumine admin news submit --edition-id 42 --lease-token <token> \
|
|
720
|
+
--file editorial.json --model "Claude" --json
|
|
721
|
+
lumine admin news print --json
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
The Twinkle Newspaper (Build app 1929) is normally printed by a community
|
|
725
|
+
member spending their own AI Energy. Making sure today's paper exists is part
|
|
726
|
+
of every delegated website-management run: check `lumine admin news` early in
|
|
727
|
+
the run, and if `printedToday` is false with no edition `pending` or
|
|
728
|
+
`generating`, print it.
|
|
729
|
+
|
|
730
|
+
**Preferred: write the editorial yourself.** `news claim` reserves today's
|
|
731
|
+
edition under the server's generation lease and returns the exact canonical
|
|
732
|
+
event digest the server would otherwise send to its own model, so no provider
|
|
733
|
+
API credits are spent. Write a `GeneratedEditorial` JSON and send it back with
|
|
734
|
+
`news submit` within the ten-minute lease. The server treats the editorial as
|
|
735
|
+
untrusted regardless of author: every story must cite an exact `eventKey`
|
|
736
|
+
from the digest, front-page `sourceQuote`s must be verbatim contiguous
|
|
737
|
+
passages of the cited event's summary (invalid quotes are replaced with
|
|
738
|
+
canonical text), section and page layout are server-enforced, announcements
|
|
739
|
+
are appended verbatim outside your output, and source visibility is
|
|
740
|
+
re-checked transactionally at commit.
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
type GeneratedEditorial = {
|
|
744
|
+
mastheadHeadline: string;
|
|
745
|
+
mastheadDeck: string;
|
|
746
|
+
lead: { eventKey: string; headline: string; summary: string; sourceQuote: string } | null;
|
|
747
|
+
stories: Array<{ eventKey: string; headline: string; summary: string; sourceQuote: string }>;
|
|
748
|
+
editorsNote: string;
|
|
749
|
+
};
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
Editorial rules (the same ones the server's own model works under): use only
|
|
753
|
+
the supplied events — never world news, invented names, invented statistics,
|
|
754
|
+
or unsupported claims. Subjects and shared Daily Reflections are the primary
|
|
755
|
+
authored material; only a `section: "front"` event may be the lead. Preserve
|
|
756
|
+
substance, names, and numbers. Do not mention official announcements (the
|
|
757
|
+
server adds them), and give non-front events an empty `sourceQuote`.
|
|
758
|
+
|
|
759
|
+
Claim edge cases: a quiet day (no editorial events) is committed as the
|
|
760
|
+
canonical quiet edition at claim time — no editorial needed, the response
|
|
761
|
+
says so. If the claim is not submitted before the lease expires, the server's
|
|
762
|
+
press worker falls back to generating the edition itself. `news submit`
|
|
763
|
+
failing with `CLI_ADMIN_NEWS_CLAIM_LOST` means the lease was superseded —
|
|
764
|
+
re-check `lumine admin news` and claim again only if the paper still needs
|
|
765
|
+
printing.
|
|
766
|
+
|
|
767
|
+
**Repairing a past edition.** `news claim --date YYYY-MM-DD` leases an
|
|
768
|
+
already-printed historical edition and returns a fresh digest of its original
|
|
769
|
+
coverage window (primary Subjects/Reflections are re-projected from canonical
|
|
770
|
+
tables, and anything since deleted or made private drops out). Submitting
|
|
771
|
+
appends the next revision — every prior press run stays browsable in the
|
|
772
|
+
archive, and repairs never re-notify subscribers (only a day's first revision
|
|
773
|
+
does). Today's edition is never repaired this way; refreshing today is the
|
|
774
|
+
Newspaper owner's website-only action. Repair only when an edition is
|
|
775
|
+
genuinely degraded (missing masthead, missing lead, empty pages), not to
|
|
776
|
+
rewrite history editorially.
|
|
777
|
+
|
|
778
|
+
**Fallback: queue the server's own model.** `news print` reserves the edition
|
|
779
|
+
and lets the server's press worker write it (spends provider credits). It is
|
|
780
|
+
idempotent per day: it queues a new edition when today has none, requeues a
|
|
781
|
+
retry when today's only attempts failed, and returns `already_done` when the
|
|
782
|
+
paper is printed or being typeset.
|
|
783
|
+
|
|
784
|
+
Neither path ever reprints or refreshes an already-printed edition —
|
|
785
|
+
refreshing is the Newspaper owner's website-only action. The acting bot is
|
|
786
|
+
recorded as the requester, and the management bots are exempt from AI Energy
|
|
787
|
+
for newspaper generation: the platform absorbs the cost, exactly like their
|
|
788
|
+
coin-exempt recommends and rewards. When a day's first edition is printed,
|
|
789
|
+
the server notifies the app's notification subscribers (users can mute the
|
|
790
|
+
app or unsubscribe in the app; the bots never need to send anything). All
|
|
791
|
+
three mutations require the `news:print` scope (in every run's base scopes)
|
|
792
|
+
and are audited as `news.print` / `news.claim` / `news.submit` against
|
|
793
|
+
`news_edition` targets.
|
|
794
|
+
|
|
795
|
+
```ts
|
|
796
|
+
type NewsStatus = Success<{
|
|
797
|
+
newspaper: {
|
|
798
|
+
dayIndex: number;
|
|
799
|
+
dateKey: string; // YYYY-MM-DD
|
|
800
|
+
printedToday: boolean;
|
|
801
|
+
generationStatus:
|
|
802
|
+
| "available" // no edition requested today
|
|
803
|
+
| "pending"
|
|
804
|
+
| "generating"
|
|
805
|
+
| "ready"
|
|
806
|
+
| "failed";
|
|
807
|
+
failureMessage: string | null;
|
|
808
|
+
attempts: number | null;
|
|
809
|
+
latestPrinted: {
|
|
810
|
+
dayIndex: number;
|
|
811
|
+
dateKey: string;
|
|
812
|
+
generatedAt: number | null;
|
|
813
|
+
sourceEventCount: number;
|
|
814
|
+
revisionNumber: number;
|
|
815
|
+
} | null; // most recent printed edition, possibly a previous day
|
|
816
|
+
nextEditionAt: number;
|
|
817
|
+
printDecision: "already_printed" | "in_progress" | "retry" | "create";
|
|
818
|
+
requestedAction?: "none" | "retry" | "create"; // print responses only
|
|
819
|
+
};
|
|
820
|
+
}>;
|
|
821
|
+
|
|
822
|
+
type NewsPrint = NewsStatus; // "success" (queued) or "already_done"
|
|
823
|
+
|
|
824
|
+
type NewsClaim = Success<{
|
|
825
|
+
newspaper: NewsStatus["data"]["newspaper"] & {
|
|
826
|
+
quietEditionPrinted?: boolean;
|
|
827
|
+
};
|
|
828
|
+
claim: {
|
|
829
|
+
editionId: number;
|
|
830
|
+
dayIndex: number;
|
|
831
|
+
dateKey: string;
|
|
832
|
+
leaseToken: string;
|
|
833
|
+
leaseExpiresAt: number;
|
|
834
|
+
coverage: { startedAt: number; endedAt: number };
|
|
835
|
+
maxSourceQuoteLength: number;
|
|
836
|
+
announcementCount: number;
|
|
837
|
+
events: Array<{
|
|
838
|
+
eventKey: string;
|
|
839
|
+
kind: string;
|
|
840
|
+
section: string; // front | community | notices | scores | marketplace
|
|
841
|
+
occurredAt: number;
|
|
842
|
+
priority: number;
|
|
843
|
+
title: string;
|
|
844
|
+
summary: string;
|
|
845
|
+
payload: unknown; // may include author and canonical topComments
|
|
846
|
+
}>;
|
|
847
|
+
} | null; // null: already printed/typesetting, or the quiet edition auto-committed
|
|
848
|
+
}>;
|
|
849
|
+
|
|
850
|
+
type NewsSubmit = NewsStatus; // "success"; newspaper includes revisionNumber
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
## Audit history
|
|
854
|
+
|
|
855
|
+
```bash
|
|
856
|
+
lumine admin audit list --json
|
|
857
|
+
lumine admin audit list --run current --json
|
|
858
|
+
lumine admin audit list --run last --actions recommendation.skip --json
|
|
859
|
+
lumine admin audit list --target dailyReflection:99 --full --json
|
|
860
|
+
lumine admin audit list --cursor '<cursor>' --limit 50 --json
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Lists the operator's own private audit events, newest first, so an agent can
|
|
864
|
+
see what earlier runs did. `--run` accepts `current`, `last`, or a run ID;
|
|
865
|
+
`--target` accepts `<targetType>:<id>`; `--actions` is a comma-separated
|
|
866
|
+
action list. Filters are bound into the cursor exactly like the other list
|
|
867
|
+
cursors. The walk is a bounded descending primary-key traversal over the
|
|
868
|
+
existing operator/run/target audit indexes.
|
|
869
|
+
|
|
870
|
+
Rows are compact by default (identifiers, action, target, result, response
|
|
871
|
+
`status`/`changed`, timestamps, and the request's idempotency key). `--full`
|
|
872
|
+
adds the stored `beforeState`, `afterState`, `responseJson`, and `metadata`
|
|
873
|
+
payloads — the same data the mutation already returned to this operator. The
|
|
874
|
+
private per-attempt fencing token is never returned. Reading audit history
|
|
875
|
+
requires only the `content:read` scope of an active run.
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
type AuditEvent = {
|
|
879
|
+
id: number;
|
|
880
|
+
runId: number | null;
|
|
881
|
+
publicActorUserId: number | null;
|
|
882
|
+
sessionKind: string;
|
|
883
|
+
action: string;
|
|
884
|
+
targetType: string | null;
|
|
885
|
+
targetId: number | null;
|
|
886
|
+
requestId: string;
|
|
887
|
+
result: string; // in_progress | completed | failed | partial_failure | bookkeeping_pending
|
|
888
|
+
status: string | null; // response status, e.g. success | already_done
|
|
889
|
+
changed: boolean | null;
|
|
890
|
+
createdAt: number;
|
|
891
|
+
completedAt: number | null;
|
|
892
|
+
// Present only with --full:
|
|
893
|
+
beforeState?: unknown;
|
|
894
|
+
afterState?: unknown;
|
|
895
|
+
responseJson?: unknown;
|
|
896
|
+
metadata?: unknown;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
type AuditList = Success<{
|
|
900
|
+
events: AuditEvent[];
|
|
901
|
+
pagination: Pagination;
|
|
902
|
+
}>;
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
## Persona-backed comments and replies
|
|
620
906
|
|
|
621
907
|
```bash
|
|
622
908
|
lumine admin daily-run start --identity auto --comment-mode draft --json
|
|
@@ -626,21 +912,44 @@ lumine admin daily-run start --identity ciel --comment-mode post \
|
|
|
626
912
|
--run-key daily:2026-08-06:comments --json
|
|
627
913
|
lumine admin comment draft 123 --identity ciel \
|
|
628
914
|
--idempotency-key comment-123-draft-v1 --json
|
|
915
|
+
lumine admin comment draft dailyReflection:99 --json
|
|
916
|
+
lumine admin comment reply comment:456 --json
|
|
629
917
|
lumine admin comment post --draft-id 77 \
|
|
630
918
|
--idempotency-key comment-123-post-v1 --json
|
|
631
919
|
```
|
|
632
920
|
|
|
921
|
+
A draft targets one of:
|
|
922
|
+
|
|
923
|
+
- `subject:<id>` (or a bare numeric ID) — a top-level comment on the subject;
|
|
924
|
+
- `aiStory:<id>` / `dailyReflection:<id>` — a top-level comment on the
|
|
925
|
+
standalone post;
|
|
926
|
+
- `comment:<id>` — a public reply to that specific comment. `comment reply`
|
|
927
|
+
is the same operation and requires a comment target.
|
|
928
|
+
|
|
929
|
+
A reply's container resolves canonically from the target comment: its subject,
|
|
930
|
+
or its AI Story / Daily Reflection root. Comments under any other root are
|
|
931
|
+
rejected with `CLI_ADMIN_UNSUPPORTED_REPLY_ROOT`. Replies to Zero/Ciel
|
|
932
|
+
comments and to notification comments are rejected with
|
|
933
|
+
`CLI_ADMIN_INVALID_REPLY_TARGET` — the bots never thread with themselves or
|
|
934
|
+
each other, and a human's later reply to a delegated comment still enters the
|
|
935
|
+
existing autonomous comment-assistant pipeline. Published replies carry the
|
|
936
|
+
ordinary thread linkage (thread root and reply-to), and notification fan-out
|
|
937
|
+
uses the normal canonical path.
|
|
938
|
+
|
|
633
939
|
```ts
|
|
634
940
|
type CommentDraft = Success<{
|
|
635
941
|
draft: {
|
|
636
942
|
id: number;
|
|
637
943
|
runId: number;
|
|
638
|
-
|
|
639
|
-
|
|
944
|
+
targetType: "subject" | "comment" | "aiStory" | "dailyReflection";
|
|
945
|
+
targetId: number;
|
|
946
|
+
targetUrl: string;
|
|
947
|
+
subjectId: number | null; // container subject; null for standalone posts
|
|
948
|
+
subjectUrl: string | null;
|
|
640
949
|
publicActorUserId: number;
|
|
641
950
|
commentMode: "draft" | "post";
|
|
642
951
|
personaRevision: string; // SHA-256; raw prompt is never returned
|
|
643
|
-
contextRevision: string; // SHA-256 of canonical
|
|
952
|
+
contextRevision: string; // SHA-256 of canonical container/comments/target
|
|
644
953
|
decision: "draft" | "skip";
|
|
645
954
|
reason: string | null;
|
|
646
955
|
content: string | null;
|
|
@@ -663,24 +972,44 @@ type CommentPost = CommentGet & {
|
|
|
663
972
|
};
|
|
664
973
|
published: {
|
|
665
974
|
commentId: number;
|
|
666
|
-
|
|
667
|
-
|
|
975
|
+
targetType: "subject" | "comment" | "aiStory" | "dailyReflection";
|
|
976
|
+
targetId: number;
|
|
977
|
+
subjectId: number | null;
|
|
978
|
+
subjectUrl: string | null;
|
|
979
|
+
containerUrl: string;
|
|
668
980
|
commentUrl: string;
|
|
669
981
|
};
|
|
670
982
|
};
|
|
671
983
|
};
|
|
672
984
|
```
|
|
673
985
|
|
|
674
|
-
The server loads the canonical subject
|
|
675
|
-
then invokes the existing exact Zero/Ciel
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
986
|
+
The server loads the canonical container (subject or standalone post) and its
|
|
987
|
+
complete visible comment context, then invokes the existing exact Zero/Ciel
|
|
988
|
+
system prompt through the shared response assembler with a mode-specific
|
|
989
|
+
decision policy (comment vs reply). The raw prompt is never returned or
|
|
990
|
+
audited. The model—not regexes or keyword rules—chooses `draft` or `skip`
|
|
991
|
+
under the run policy.
|
|
992
|
+
|
|
993
|
+
Draft IDs are bound to operator, run, public bot, target, comment mode,
|
|
994
|
+
context revision, persona revision, expiry, and idempotency key. The context
|
|
995
|
+
revision covers the container, every visible comment, and the target binding,
|
|
996
|
+
so a thread that changes between draft and publish rejects publication.
|
|
997
|
+
Posting locks the draft and context in the same transaction as the ordinary
|
|
998
|
+
comment insert. Changed context or persona rejects publication and requires
|
|
999
|
+
regeneration. Retries return the already-published comment instead of
|
|
1000
|
+
duplicating it. Secret-subject gating applies whenever the container is a
|
|
1001
|
+
subject, including replies inside it.
|
|
1002
|
+
|
|
1003
|
+
Draft idempotency keys are permanent per operator: supplying a key that an
|
|
1004
|
+
earlier run (or another target) already used fails rather than resolving to
|
|
1005
|
+
the old reservation — normally as the audit layer's
|
|
1006
|
+
`CLI_ADMIN_AUDIT_IDENTITY_MISMATCH` (different run) or
|
|
1007
|
+
`CLI_ADMIN_IDEMPOTENCY_KEY_MISMATCH` (different target), with
|
|
1008
|
+
`CLI_ADMIN_DRAFT_KEY_REUSED` as the draft-table backstop. All three mean the
|
|
1009
|
+
same thing: embed the run or date in any caller-supplied draft key and retry
|
|
1010
|
+
with a fresh one. Note that the agent's
|
|
1011
|
+
own `subject effort set` or `creator set-made-by-poster` between draft and
|
|
1012
|
+
post changes the context revision — order those mutations before drafting.
|
|
684
1013
|
|
|
685
1014
|
## Audit, sockets, and deployment
|
|
686
1015
|
|
|
@@ -692,9 +1021,9 @@ cookies, passwords, or raw system prompts.
|
|
|
692
1021
|
|
|
693
1022
|
Retry acquisition and completion are row-locked and fenced by a private
|
|
694
1023
|
per-attempt token, so an expired request cannot overwrite a newer retry. A new
|
|
695
|
-
recommendation
|
|
696
|
-
canonical prior-recommender approval and the
|
|
697
|
-
independently retryable.
|
|
1024
|
+
recommendation commits atomically (the management bots are exempt from the
|
|
1025
|
+
recommendation coin charge); the canonical prior-recommender approval and the
|
|
1026
|
+
separate 3-Twinkle reward remain independently retryable.
|
|
698
1027
|
|
|
699
1028
|
Public content actions use ordinary Twinkle fan-out:
|
|
700
1029
|
|
|
@@ -705,9 +1034,11 @@ Public content actions use ordinary Twinkle fan-out:
|
|
|
705
1034
|
- effort/creator changes emit `edit_content`;
|
|
706
1035
|
- Featured changes emit a canonical `home_outdated` refresh.
|
|
707
1036
|
|
|
708
|
-
Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql`
|
|
709
|
-
deploying the API.
|
|
710
|
-
|
|
1037
|
+
Apply `twinkle-api/scripts/migrations/add-lumine-admin-delegation.sql` and
|
|
1038
|
+
then `add-lumine-admin-comment-targets.sql` before deploying the API. They add
|
|
1039
|
+
only focused daily-run, rotation, draft, and audit tables/columns and indexes;
|
|
1040
|
+
there are no runtime schema checks. The comment-targets migration backfills
|
|
1041
|
+
existing subject drafts into the generalized target columns. The local CLI changes
|
|
711
1042
|
are not available to users until a separately authorized npm publication.
|
|
712
1043
|
|
|
713
1044
|
Legacy aliases such as `subjects list`, `subjects get`, `subjects featured`,
|