@stage5/lumine 0.3.23 → 0.3.24

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.
@@ -73,6 +73,7 @@ export function runAdmin(
73
73
  "reward-review": ["list"],
74
74
  approvals: ["list"],
75
75
  todo: ["list"],
76
+ followup: ["list"],
76
77
  escalation: ["list"],
77
78
  featured: ["list", "history"],
78
79
  sponsor: ["applications"],
@@ -192,6 +193,7 @@ export async function dailyRunGather(options) {
192
193
  // flags: a required report section (agent-guides/lumine-admin.md)
193
194
  step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
194
195
  step("todos", ["todo", "list"], "todos.json");
196
+ step("personal-followups", ["followup", "list"], "personal-followups.json");
195
197
  step("escalations", ["escalation", "list"], "escalations.json");
196
198
  step("featured-list", ["featured", "list"], "featured-start.json");
197
199
  step(
@@ -0,0 +1,66 @@
1
+ function seenAt(value) {
2
+ if (value == null || !Number.isFinite(Number(value)) || Number(value) <= 0)
3
+ return "unknown";
4
+ const date = new Date(Number(value) * 1000);
5
+ return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString();
6
+ }
7
+
8
+ function eventTypes(counts) {
9
+ const entries = Object.entries(counts || {}).sort(([a], [b]) =>
10
+ a.localeCompare(b),
11
+ );
12
+ return entries.length
13
+ ? `; ${entries.map(([type, count]) => `${type}: ${count}`).join(", ")}`
14
+ : "";
15
+ }
16
+
17
+ /** Display observed device events separately from accounts and physical visits. */
18
+ export function formatIdentityDeviceUsage(inspection) {
19
+ const devices = inspection.sharedDevices || [];
20
+ const coverage = inspection.evidenceCoverage || {};
21
+ const lines = [];
22
+ if (devices.length) {
23
+ lines.push(
24
+ `Device history (${coverage.deviceLookbackDays || "unknown"} days): recorded account events, not visits or unique sessions.`,
25
+ );
26
+ for (const device of devices) {
27
+ const usage = device.usage;
28
+ if (!usage) {
29
+ lines.push(
30
+ ` Device ${device.key}: usage counts unavailable from this API; ${device.accountCount ?? "unknown"} observed account(s).`,
31
+ );
32
+ continue;
33
+ }
34
+ if (usage.status === "not_read" || usage.eventCount == null) {
35
+ lines.push(
36
+ ` Device ${device.key}: usage and sharing not read (${usage.truncatedBy?.join(", ") || "incomplete evidence"}).`,
37
+ );
38
+ continue;
39
+ }
40
+ const partial = usage.status !== "complete";
41
+ const prefix = partial ? "at least " : "";
42
+ lines.push(
43
+ ` Device ${device.key}: ${prefix}${usage.eventCount} recorded event(s), ${prefix}${device.accountCount} account(s)${eventTypes(usage.eventsByType)}.`,
44
+ ` First observed ${seenAt(usage.firstSeenAt)}; last observed ${seenAt(usage.lastSeenAt)}.`,
45
+ );
46
+ if (partial) {
47
+ lines.push(
48
+ ` Partial history (${usage.truncatedBy?.join(", ") || "incomplete evidence"}); older events and other accounts may be missing.`,
49
+ );
50
+ }
51
+ for (const account of usage.accounts || []) {
52
+ lines.push(
53
+ ` #${account.userId} ${account.username || "(unknown account)"}: ${prefix}${account.eventCount} recorded event(s)${eventTypes(account.eventsByType)}; first ${seenAt(account.firstSeenAt)}, last ${seenAt(account.lastSeenAt)}.`,
54
+ );
55
+ }
56
+ if (usage.accountsTruncated)
57
+ lines.push(" Account detail list capped; totals include omitted accounts.");
58
+ }
59
+ }
60
+ if (coverage.truncated) {
61
+ lines.push(
62
+ `Device discovery/evidence is incomplete (${coverage.truncatedBy?.join(", ") || "history limit"}); additional devices or accounts may exist.`,
63
+ );
64
+ }
65
+ return lines;
66
+ }
package/lib/admin.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  printOwnerTrace,
40
40
  } from "./admin-owner-trace.js";
41
41
  import { formatChatReportResult } from "./admin-chat-reports.js";
42
+ import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
42
43
  import {
43
44
  buildTeacherBranchOperation,
44
45
  printTeacherBranchResult,
@@ -2478,6 +2479,21 @@ export function parseAdminOperation(options) {
2478
2479
  },
2479
2480
  );
2480
2481
  }
2482
+ if (action === "device" && target === "block-signup") {
2483
+ if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
2484
+ throw cliValidationError("Pass --device-key with the exact key from identity inspect.");
2485
+ }
2486
+ return writeOperation(
2487
+ "ai-bucket.device.signup.block", "POST",
2488
+ `/cli/admin/ai-buckets/${bucketId}/signup-devices`,
2489
+ {
2490
+ sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
2491
+ deviceKey: options.adminDeviceKey,
2492
+ expectedUserIds: parseAiBucketUserIds(options.adminUserIds),
2493
+ note: parseAiBucketNote(options.note),
2494
+ },
2495
+ );
2496
+ }
2481
2497
  if (action === "note" && target === "set") {
2482
2498
  return writeOperation(
2483
2499
  "ai-bucket.note.set",
@@ -3174,6 +3190,25 @@ export function parseAdminOperation(options) {
3174
3190
  );
3175
3191
  }
3176
3192
 
3193
+ if (namespace === "followup" || namespace === "followups") {
3194
+ if (!action || action === "list") {
3195
+ return readOperation("followup.list", withQuery("/cli/admin/personal-followups", {
3196
+ cursor: options.cursor || undefined,
3197
+ status: options.adminStatus === "all" ? "all" : "pending",
3198
+ }));
3199
+ }
3200
+ if (action === "update") {
3201
+ const id = parseRequiredInteger(target, "Follow-up ID", 1);
3202
+ const revision = parseRequiredInteger(options.followupRevision, "--revision", 1);
3203
+ const note = String(options.note || "").trim();
3204
+ if (!note || note.length > MAX_TODO_NOTE_LENGTH) throw cliValidationError("Record the actual work, evidence and reply outcome (or blocker) in --note, up to 4000 characters.");
3205
+ return writeOperation("followup.update", "PUT", `/cli/admin/personal-followups/${id}`, {
3206
+ revision, status: parseTodoStatus(options.adminStatus), note,
3207
+ });
3208
+ }
3209
+ throw cliValidationError("Usage: lumine admin followup list [--cursor <id>] [--status all] | followup update <id> --revision <n> --status <status> --note <evidence and outcome>.");
3210
+ }
3211
+
3177
3212
  if (namespace === "todo" || namespace === "todos") {
3178
3213
  if (!action || action === "list") {
3179
3214
  return readOperation(
@@ -6814,6 +6849,7 @@ function printAdminResult({ operation, result }) {
6814
6849
  );
6815
6850
  } else {
6816
6851
  printTodoItems(report.carryoverTodos?.items || [], "Unfinished work");
6852
+ printPersonalFollowups(report.personalFollowups);
6817
6853
  }
6818
6854
  if (report.sponsorIntegrity) {
6819
6855
  const pendingApplications = Number.isSafeInteger(
@@ -6870,6 +6906,7 @@ function printAdminResult({ operation, result }) {
6870
6906
  );
6871
6907
  }
6872
6908
  }
6909
+ for (const line of formatIdentityDeviceUsage(inspection)) console.log(line);
6873
6910
  return;
6874
6911
  }
6875
6912
  if (Array.isArray(data.escalations)) {
@@ -6912,6 +6949,8 @@ function printAdminResult({ operation, result }) {
6912
6949
  }
6913
6950
  return;
6914
6951
  }
6952
+ if (data.personalFollowups) printPersonalFollowups(data.personalFollowups);
6953
+ if (data.personalFollowup) printPersonalFollowups({ items: [data.personalFollowup], nextCursor: null });
6915
6954
  if (Array.isArray(data.todos)) {
6916
6955
  printTodoItems(data.todos, "Private carry-over work");
6917
6956
  if (data.truncated) {
@@ -7191,6 +7230,18 @@ function printFeaturedHistoryEvent(event) {
7191
7230
  );
7192
7231
  }
7193
7232
 
7233
+ function printPersonalFollowups(handoff) {
7234
+ if (!handoff) return;
7235
+ console.log(`Personal follow-ups: ${handoff.items.length}${handoff.nextCursor ? "+" : ""}`);
7236
+ for (const item of handoff.items) {
7237
+ console.log(` #${item.id} r${item.revision} ${item.status} — ${item.title}`);
7238
+ console.log(` ${item.details}`);
7239
+ console.log(` Source: ${item.source.type} ${item.source.id}, ${item.source.rootType} ${item.source.rootId}; member ${item.requesterUserId}`);
7240
+ if (item.lastProgressNote) console.log(` Progress: ${item.lastProgressNote}`);
7241
+ }
7242
+ if (handoff.nextCursor) console.log(` Continue: lumine admin followup list --cursor ${handoff.nextCursor}`);
7243
+ }
7244
+
7194
7245
  function printTodoItems(items, heading) {
7195
7246
  console.log(`${heading}: ${items.length} item(s).`);
7196
7247
  for (const todo of items) {
package/lib/commands.js CHANGED
@@ -2592,6 +2592,7 @@ export function parseArgs(args) {
2592
2592
  adminReviewed: Boolean(raw.reviewed),
2593
2593
  adminSeverity: raw.severity ? String(raw.severity) : "",
2594
2594
  adminStatus: raw.status ? String(raw.status) : "",
2595
+ followupRevision: raw.revision ? String(raw.revision) : "",
2595
2596
  adminDecision: raw.decision ? String(raw.decision) : "",
2596
2597
  adminWaitMs: raw.waitMs ? String(raw.waitMs) : "",
2597
2598
  adminBrowserPath: raw.browserPath ? String(raw.browserPath) : "",
@@ -2673,6 +2674,7 @@ export function parseArgs(args) {
2673
2674
  ? String(raw.addIds)
2674
2675
  : "",
2675
2676
  adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
2677
+ adminDeviceKey: raw.deviceKey ? String(raw.deviceKey) : "",
2676
2678
  adminLabel: raw.label ? String(raw.label) : "",
2677
2679
  adminUserIds: raw.userIds ? String(raw.userIds) : "",
2678
2680
  adminUser: raw.user ? String(raw.user) : "",
@@ -3104,6 +3106,7 @@ export function printHelp() {
3104
3106
  lumine admin ai-bucket create --label <name> --note <text> [--json]
3105
3107
  lumine admin ai-bucket get --bucket-id <id> [--json]
3106
3108
  lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
3109
+ lumine admin ai-bucket device block-signup --bucket-id <id> --user <source-user-id> --device-key <inspection-key> --user-ids <reviewed-device-users> --note <reason> [--json]
3107
3110
  lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
3108
3111
  lumine admin ai-email-policy get --email <address> [--json]
3109
3112
  lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -502,9 +502,9 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
502
502
  - Template values describe types, not answers to copy. All object keys are required and extra keys are rejected, including inside nested objects; {} means an exactly empty object, not a free-form dictionary. A non-empty array uses only its first item as the template for every returned item, with no fixed length; mixed examples do not declare a union. [] allows arbitrary JSON items. A null template accepts any JSON primitive (string, number, boolean or null), but the key is still required. Use 'number' for fractional numbers; 0 and other integer examples require integers. State content requirements in prompt or instructions. GPT routes retain descriptive string examples as field guidance, not enforced enum values.
503
503
  - GPT models prefer native JSON Schema output for compatible shapes. Shapes outside the strict provider subset, such as [] with arbitrary items or deeply nested structures, use prompt-based JSON generation with the same final validation. A provider rejection specifically of the native schema triggers one prompt-based fallback; auth, quota, safety and unrelated request failures do not trigger that fallback. Validation retries preserve the original task and instructions. The returned object/result shape and streaming callbacks are unchanged.
504
504
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
505
- - Omit model to use the normal Lite/Medium/High routing. model accepts claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5, and every explicit model must be paired with thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
506
- - thinkingMode low uses GPT-6 Luna and consumes the viewer's AI Energy from confirmed provider usage; its smaller model is usually cheaper than Medium or High.
507
- - thinkingMode medium uses GPT-6 Luna with medium reasoning and consumes normal AI Energy.
505
+ - Omit model to use the normal Lite/Medium/High routing. Lumine Light defaults to claude-haiku-5-5; gpt-6-luna remains its second choice. Both model IDs accept Low, Medium or High thinking. Explicit claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5 still require thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
506
+ - thinkingMode low uses Claude Haiku 5.5 with low adaptive thinking and consumes the viewer's AI Energy from confirmed provider usage. Set model: 'gpt-6-luna' to choose Luna instead.
507
+ - thinkingMode medium is Lumine Light: it uses Claude Haiku 5.5 with medium adaptive thinking and consumes normal AI Energy. Set model: 'gpt-6-luna' to keep Luna as the second choice.
508
508
  - thinkingMode high without model uses Claude Sonnet 5.5 with high adaptive thinking and consumes high AI Energy. Explicit model: 'gpt-6.1-sol' selects GPT-6.1 Sol with xhigh reasoning at the same High AI Energy tier; model: 'claude-sonnet-5-5' names Sonnet 5.5 directly.
509
509
  - claude-sonnet-5-5 and claude-opus-5-5 use Anthropic adaptive High thinking and debit confirmed provider usage at the High tier.
510
510
  - Pass onStatus, onReasoning, and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
@@ -513,7 +513,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
513
513
  - Put a user-facing field such as producerNotes in expectedStructure when commentary must be part of the validated final object rather than transient reasoning progress.
514
514
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
515
515
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
516
- - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while Anthropic routes (High without a model, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
516
+ - The server validates the final shape; OpenAI/xAI routes can retry malformed output, while Anthropic routes (claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
517
517
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 'number' }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
518
518
  - onChatStatus(listener) | scopes: none
519
519
  - Returns: unsubscribe function
@@ -130,13 +130,35 @@ private Lumine audit log; no public bot identity is involved.
130
130
  canonical bucket. Use it to distinguish quota bookkeeping from moderation;
131
131
  the note itself changes no access, ban, or identity rules.
132
132
 
133
- This surface is quota bookkeeping only. It cannot ban accounts, block signup,
133
+ The commands above are quota bookkeeping only. They cannot ban accounts, block signup,
134
134
  add IP/device/risk-key rules, or infer an account family. Identification
135
135
  remains a human/LLM evidence judgment and must be explicitly requested by
136
136
  Mikey; routine administrator runs still escalate suspected alternate accounts
137
137
  and never auto-enforce. `accounts add` and `note set` still accept only an
138
138
  existing **unbanned** bucket.
139
139
 
140
+ For an exact device Mikey explicitly authorizes blocking, use the separate
141
+ signup-only action after reviewing `identity inspect`:
142
+
143
+ ```bash
144
+ lumine admin ai-bucket device block-signup --bucket-id 10 --user 3127 \
145
+ --device-key 0123456789abcdef --user-ids 3127 \
146
+ --note "Mikey approved this reviewed device for the household signup block" --json
147
+ ```
148
+
149
+ `--user` is the source account ID; `--user-ids` is the complete reviewed list
150
+ of accounts observed on that device, not every member of the household.
151
+ The API resolves the inspection handle internally, rechecks bounded 365-day
152
+ device evidence, requires the exact expected account set, and rejects devices
153
+ used by anyone outside the existing bucket. Incomplete history, unresolved
154
+ handles and a rule belonging to another bucket stop the action. The bucket
155
+ must already have an active **signup-only** restriction. This cannot turn on
156
+ a bucket ban, change full-site bans, add network rules or infer membership.
157
+ The response confirms the canonical rule and restriction without returning the
158
+ raw device ID. The private audit records the request before evidence is read
159
+ and records successful completion separately. Routine reviews still never
160
+ auto-enforce; this action requires Mikey's explicit instruction for the device.
161
+
140
162
  ### Shared verified-email policies
141
163
 
142
164
  A verified address proves that an account can use that mailbox; it does not
@@ -198,6 +220,22 @@ IP evidence, private messages, or unrelated activity. `--include-private-evidenc
198
220
  adds DOB values and verified email addresses only; exact device IDs are never
199
221
  returned.
200
222
 
223
+ `sharedDevices[].usage` reports recorded event counts, first/last observed Unix
224
+ timestamps, an `eventsByType` breakdown, and the same details per account. These
225
+ come from `ai_usage_session_evidence`, which records the exact device identifier;
226
+ `users_actions` has account/IP activity but no device identifier. Counts include
227
+ events such as login, logout, socket binding and AI use, **not unique visits or
228
+ physical-device sessions**. Only events attached to an account are counted.
229
+
230
+ Usage `status: complete` means the queried device's records fit inside the
231
+ 365-day lookback and read limit. `partial` counts are lower bounds, with observed
232
+ dates and `truncatedBy` explaining the gap. `not_read` has a null event count,
233
+ never zero. `accountsTruncated` separately marks a capped account-detail list;
234
+ its totals still include omitted accounts. Device discovery can be truncated
235
+ even when an individual device's usage is complete: always check the overall
236
+ `evidenceCoverage` too. Human-readable CLI output includes these distinctions;
237
+ older APIs explicitly report that usage counts are unavailable.
238
+
201
239
  The CLI sends `identity inspect` and `identity network` as reads, so it keeps
202
240
  no copy of the result on disk; the server still records who looked, when and
203
241
  why, one audit row per call. When a case needs a kept copy (a bullying or police
@@ -1647,6 +1685,39 @@ to a child):
1647
1685
 
1648
1686
  ## Private carry-over todos
1649
1687
 
1688
+ ### Zero and Ciel's personal follow-ups
1689
+
1690
+ Each bot has a separate persistent list of commitments made to members. The
1691
+ API reply tools save a concrete task before saying it is on the bot's list;
1692
+ they link the real source comment/chat and keep the requesting member and bot
1693
+ server-owned. Retries of the same message return the same task. Members can
1694
+ ask that bot to read or cancel their own tasks. This does not approve any
1695
+ administrative action, deployment, reward, or publication.
1696
+
1697
+ Full `daily-run start` returns `personalFollowups` for the assigned bot;
1698
+ `daily-run gather` saves its first page in `personal-followups.json`. Follow
1699
+ `nextCursor` to exhaustion. Other bots' tasks wait for their own turns, and
1700
+ scoped sessions do not surface or modify the list.
1701
+
1702
+ ```bash
1703
+ lumine admin followup list --json
1704
+ lumine admin followup list --cursor 100 --json
1705
+ lumine admin followup update 17 --revision 2 --status completed --note "Played the requested path on published version 400; observed ...; replied in original comment 91 (reply 103)." --json
1706
+ ```
1707
+
1708
+ Read the original context, do the promised work, keep evidence, and report the
1709
+ real result to the member in the original context under the existing reply
1710
+ rules. Do not move a private request into a public post. Build replies still
1711
+ require exact-version review evidence. Record `blocked` with a concrete next
1712
+ step when access, the creator, or Mikey's decision is needed. A completed
1713
+ daily run never completes promises automatically. Update requires the current
1714
+ revision and the assigned bot's active full run; stale or sibling updates are
1715
+ refused. Daily reports include pending follow-ups and their latest progress.
1716
+
1717
+ Apply `add-ai-daily-followups.sql` before deploying this API/CLI capability.
1718
+
1719
+ ### Operator carryovers
1720
+
1650
1721
  ```bash
1651
1722
  lumine admin todo list --json
1652
1723
  lumine admin todo list --status all --json