@stage5/lumine 0.2.92 → 0.2.95

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
@@ -51,6 +51,16 @@ returns the confirmed canonical limits after commit. Running the command again
51
51
  is a safe no-op that reports the already-active canonical limits. Omit the
52
52
  target to use the current workspace or selected project.
53
53
 
54
+ Lumine file storage (uploaded assets and `Twinkle.files`) is counted per
55
+ creator across all their Builds. `lumine assets storage` shows your limit, usage
56
+ and any pending request. Past the default quota, `lumine assets request-storage
57
+ [--size 500MB|1GB|2GB] [--reason "<what it is for>"]` asks Mikey for more (the
58
+ next tier up when `--size` is omitted); nothing changes until he approves it.
59
+ Mikey reviews with `lumine admin storage list`, `show <user>`,
60
+ `approve <request-id> [--size <size>]`, `reject <request-id> [--reason <text>]`,
61
+ or grants directly with `lumine admin storage grant <user> --size <size>`.
62
+ Approvals are capped at 2 GB and never lower a creator below the default.
63
+
54
64
  For team projects, Lumine mirrors the website workspace flow: choosing or
55
65
  pulling the owner's main project creates or reuses your contribution branch and
56
66
  checks out that branch locally. Saves go to your branch, so the project owner
@@ -128,6 +138,27 @@ Discovery is static and fail-closed: runtime code cannot add tools that were
128
138
  not declared in the pinned manifest, and a session refuses to connect if any
129
139
  declared handler is missing.
130
140
 
141
+ ### Connecting to a tab you already have open
142
+
143
+ In an app that offers tools, a signed-in viewer can click **Connect AI helper**
144
+ in the app toolbar (or the workspace preview toolbar). Twinkle shows a
145
+ six-character code for that open tab, valid for 10 minutes and usable once.
146
+ Then run:
147
+
148
+ ```bash
149
+ lumine app-mcp <app-url-or-id> --code ABC123
150
+ ```
151
+
152
+ The bridge attaches to that tab instead of opening a new one. The CLI must be
153
+ logged in as the same Twinkle account as the tab. The tab shows an
154
+ "AI helper connected" badge with a **Disconnect** button and an **Allow edits**
155
+ switch, which starts off: until the viewer switches it on, only tools declared
156
+ read-only in `/app-tools.json` (`"readOnly": true`, or
157
+ `"readOnlyWhen": { "action": ["list"] }` for one action argument) work.
158
+ Undeclared tools count as edits. Disconnecting, or leaving the app, ends the
159
+ session and resets the switch. `--helper-name <name>` overrides the label the
160
+ viewer sees (by default the MCP client's own name).
161
+
131
162
  ## Inspecting Build SDK data
132
163
 
133
164
  `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
@@ -0,0 +1,338 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import http from "http";
4
+ import { spawn, spawnSync } from "child_process";
5
+ import { fileURLToPath } from "url";
6
+
7
+ // Multi-step conveniences for the full daily management run. Each one runs
8
+ // the ordinary `lumine admin` commands as child processes, so pagination,
9
+ // audit, scope checks and --output handling stay exactly as documented; these
10
+ // only remove the hand-typed glue that every run used to repeat.
11
+
12
+ const LUMINE_BIN = fileURLToPath(new URL("../bin/lumine.js", import.meta.url));
13
+ const MARKED_ASSET = fileURLToPath(
14
+ new URL("./report-assets/marked.min.js", import.meta.url),
15
+ );
16
+
17
+ function runAdmin(args, { outputFile, extraEnv } = {}) {
18
+ const full = ["admin", ...args, "--json"];
19
+ if (outputFile) full.push("--output", outputFile);
20
+ const result = spawnSync(process.execPath, [LUMINE_BIN, ...full], {
21
+ encoding: "utf8",
22
+ maxBuffer: 256 * 1024 * 1024,
23
+ env: { ...process.env, ...(extraEnv || {}) },
24
+ });
25
+ let parsed = null;
26
+ try {
27
+ parsed = JSON.parse(result.stdout || "null");
28
+ } catch {
29
+ parsed = null;
30
+ }
31
+ return {
32
+ ok: result.status === 0 && parsed?.ok !== false,
33
+ status: result.status,
34
+ json: parsed,
35
+ stderr: String(result.stderr || "").slice(-2000),
36
+ };
37
+ }
38
+
39
+ function ensureDir(dir) {
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ return dir;
42
+ }
43
+
44
+ function utcDayKey(offsetDays = 0) {
45
+ const d = new Date(Date.now() + offsetDays * 86400000);
46
+ return d.toISOString().slice(0, 10);
47
+ }
48
+
49
+ // Bangkok midnight `days` calendar days before today, as an ISO timestamp.
50
+ export function bangkokCutoff(days, now = Date.now()) {
51
+ const nowBkk = new Date(now + 7 * 3600000);
52
+ const midnight = Date.UTC(
53
+ nowBkk.getUTCFullYear(),
54
+ nowBkk.getUTCMonth(),
55
+ nowBkk.getUTCDate(),
56
+ );
57
+ return new Date(midnight - days * 86400000 - 7 * 3600000).toISOString();
58
+ }
59
+
60
+ // Renders every Zero/Ciel row as readable text grouped by conversation, the
61
+ // file the conduct review reads end to end.
62
+ export function renderBotOutput({ chatMessages, comments }, file) {
63
+ const t = (ts) => new Date(ts * 1000).toISOString().slice(5, 16);
64
+ const lines = [];
65
+ const sorted = [...chatMessages].sort(
66
+ (a, b) => a.channelId - b.channelId || a.messageId - b.messageId,
67
+ );
68
+ for (const m of sorted) {
69
+ const r = m.recipient || {};
70
+ const g = m.generation || {};
71
+ lines.push(
72
+ `--- #${m.messageId} ${t(m.occurredAt)} ${m.bot} -> ${r.username}(${r.userId}) ch${m.channelId} ${m.surface} kind=${m.messageKind} src=${m.source} gen=${g.storedOutcome} err=${g.errorType}`,
73
+ String(m.content ?? ""),
74
+ );
75
+ }
76
+ lines.push("", "", "===== COMMENTS =====");
77
+ for (const c of comments) {
78
+ lines.push(
79
+ `--- comment ${c.commentId} ${t(c.occurredAt)} ${c.bot} root=${c.rootType}:${c.rootId} ${c.url}`,
80
+ String(c.content ?? ""),
81
+ );
82
+ }
83
+ fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
84
+ }
85
+
86
+ export async function dailyRunGather(options) {
87
+ const dir = ensureDir(
88
+ path.resolve(options.adminOutputDir || options.adminDir || "."),
89
+ );
90
+ const yesterday = options.adminDate || utcDayKey(-1);
91
+ const steps = [];
92
+ const step = (name, args, file) => {
93
+ const outputFile = path.join(dir, file);
94
+ const res = runAdmin(args, { outputFile });
95
+ steps.push({
96
+ name,
97
+ file,
98
+ ok: res.ok,
99
+ ...(res.ok ? {} : { error: res.json?.error || res.stderr }),
100
+ });
101
+ return res;
102
+ };
103
+ step("energy-budget", ["energy-budget"], "energy-budget.json");
104
+ step("news", ["news"], "news.json");
105
+ step("brief", ["brief"], "brief.json");
106
+ step("ai-costs-monthly", ["ai-costs", "monthly"], "ai-monthly.json");
107
+ step("ai-costs-day", ["ai-costs", "day", yesterday], "ai-day.json");
108
+ step("media-costs", ["media-costs", "monthly"], "media.json");
109
+ step(
110
+ "reward-activity",
111
+ ["reward-activity", "--date", yesterday, "--days", "7"],
112
+ "reward-activity.json",
113
+ );
114
+ step("reward-review-pending", ["reward-review", "list"], "rr-pending.json");
115
+ step("todos", ["todo", "list"], "todos.json");
116
+ step("escalations", ["escalation", "list"], "escalations.json");
117
+ step("featured-list", ["featured", "list"], "featured-start.json");
118
+ step(
119
+ "sponsor-applications",
120
+ ["sponsor", "applications", "list", "--status", "pending"],
121
+ "sponsor-applications.json",
122
+ );
123
+
124
+ // Bot output: follow the cursor until both sources are exhausted.
125
+ const chatMessages = [];
126
+ const comments = [];
127
+ let cursor = "";
128
+ let pages = 0;
129
+ let botOk = true;
130
+ for (;;) {
131
+ pages += 1;
132
+ const file = `bot-output-${pages}.json`;
133
+ const res = step(
134
+ `bot-output-page-${pages}`,
135
+ cursor ? ["bot-output", "--cursor", cursor] : ["bot-output"],
136
+ file,
137
+ );
138
+ if (!res.ok) {
139
+ botOk = false;
140
+ break;
141
+ }
142
+ const data = res.json?.data || {};
143
+ chatMessages.push(...(data.chatMessages || []));
144
+ comments.push(...(data.comments || []));
145
+ const p = data.pagination || {};
146
+ if (p.exhausted && !data.chatTruncated && !data.commentsTruncated) break;
147
+ cursor = p.nextCursor || "";
148
+ if (!cursor || pages > 50) {
149
+ botOk = false;
150
+ steps.push({
151
+ name: "bot-output",
152
+ ok: false,
153
+ error: "pagination did not exhaust",
154
+ });
155
+ break;
156
+ }
157
+ }
158
+ if (botOk) renderBotOutput({ chatMessages, comments }, path.join(dir, "bot-chats.txt"));
159
+
160
+ const summary = {
161
+ dir,
162
+ yesterdayUtc: yesterday,
163
+ botOutput: {
164
+ complete: botOk,
165
+ pages,
166
+ chatRows: chatMessages.length,
167
+ comments: comments.length,
168
+ readFile: botOk ? path.join(dir, "bot-chats.txt") : null,
169
+ },
170
+ steps,
171
+ failed: steps.filter((s) => !s.ok).map((s) => s.name),
172
+ };
173
+ fs.writeFileSync(
174
+ path.join(dir, "gather.json"),
175
+ JSON.stringify(summary, null, 2),
176
+ { mode: 0o600 },
177
+ );
178
+ return {
179
+ ok: summary.failed.length === 0,
180
+ status: summary.failed.length ? "partial_failure" : "success",
181
+ data: summary,
182
+ };
183
+ }
184
+
185
+ // Recent, provably never-Featured Subjects with the context the editorial
186
+ // ranking needs (comment count, operator view state), newest first.
187
+ export async function featuredCandidates(options) {
188
+ const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
189
+ const after = options.adminAfter || bangkokCutoff(days);
190
+ const scratch = ensureDir(
191
+ path.resolve(options.adminOutputDir || fs.mkdtempSync(path.join(process.env.TMPDIR || "/tmp", "lumine-featured-"))),
192
+ );
193
+ const subjects = runAdmin(
194
+ ["subjects", "candidates", "--after", after, "--all"],
195
+ { outputFile: path.join(scratch, "subjects.json") },
196
+ );
197
+ if (!subjects.ok) throw new Error(`subjects candidates failed: ${JSON.stringify(subjects.json?.error || subjects.stderr)}`);
198
+ const board = runAdmin(["featured", "list"]);
199
+ if (!board.ok) throw new Error("featured list failed");
200
+ const onBoard = new Set((board.json.data.subjects || []).map((s) => s.id));
201
+ const recent = (subjects.json.data.subjects || []).filter((s) => !onBoard.has(s.id));
202
+ const ids = recent.map((s) => s.id);
203
+ const history = ids.length
204
+ ? runAdmin(["featured", "history", "--subject-ids", ids.join(","), "--all"])
205
+ : { ok: true, json: { data: { subjects: [], coverage: {} } } };
206
+ if (!history.ok) throw new Error("featured history failed");
207
+ const never = new Map(
208
+ (history.json.data.subjects || []).map((s) => [s.id, s.neverFeatured]),
209
+ );
210
+ const candidates = [];
211
+ for (const s of recent) {
212
+ const detail = runAdmin(["subject", "get", String(s.id), "--include-comments"]);
213
+ const subject = detail.json?.data?.subject || {};
214
+ const humanComments = (subject.comments || []).filter(
215
+ (c) => !c.isNotification && ![7587, 8411].includes(Number(c.author?.id)),
216
+ );
217
+ candidates.push({
218
+ id: s.id,
219
+ url: s.url,
220
+ title: s.title,
221
+ author: s.author?.username || null,
222
+ createdAt: s.createdAt,
223
+ neverFeatured: never.get(s.id) ?? null,
224
+ effortLevel: s.effortLevel,
225
+ hasSecret: Boolean(s.secret?.hasSecret),
226
+ hasAttachment: Boolean(s.attachment),
227
+ description: String(s.description || "").slice(0, 300),
228
+ commentsIncluded: Boolean(subject.commentsIncluded),
229
+ humanComments: humanComments.length,
230
+ botComments: (subject.comments || []).filter((c) =>
231
+ [7587, 8411].includes(Number(c.author?.id)),
232
+ ).length,
233
+ operatorViewed: Boolean(s.operatorViewed?.viewed),
234
+ });
235
+ }
236
+ return {
237
+ ok: true,
238
+ status: "success",
239
+ data: {
240
+ postedAfter: after,
241
+ coverage: history.json.data.coverage || null,
242
+ boardCount: onBoard.size,
243
+ eligible: candidates.filter((c) => c.neverFeatured === true),
244
+ excluded: candidates
245
+ .filter((c) => c.neverFeatured !== true)
246
+ .map((c) => ({ id: c.id, title: c.title, neverFeatured: c.neverFeatured })),
247
+ note: "Eligibility only: posted after the cutoff and provably never Featured. Ranking by likely member engagement is the editor's judgment.",
248
+ },
249
+ };
250
+ }
251
+
252
+ function reportHtml(title) {
253
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title.replace(/</g, "&lt;")}</title>
254
+ <style>
255
+ :root{--bg:#fff;--fg:#1d2433;--muted:#5b6475;--line:#dde2ea;--nav:#f6f8fb;--code:#f1f3f7;--link:#2757c9}
256
+ @media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e4e8ef;--muted:#9aa3b2;--line:#2b313b;--nav:#1a1e25;--code:#222833;--link:#8fb0ff}}
257
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
258
+ nav{position:fixed;top:0;left:0;bottom:0;width:250px;overflow:auto;background:var(--nav);border-right:1px solid var(--line);padding:18px 14px}
259
+ nav h4{margin:0 0 10px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}nav a{display:block;padding:4px 6px;border-radius:6px;color:var(--fg);text-decoration:none;font-size:13px}nav a:hover{background:var(--line)}
260
+ main{margin-left:250px;padding:28px 40px;max-width:1100px}a{color:var(--link)}table{border-collapse:collapse;margin:10px 0;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top}
261
+ code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.9em}pre{background:var(--code);padding:12px;border-radius:8px;overflow:auto}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:34px}
262
+ @media (max-width:760px){nav{position:static;width:auto;height:auto;border-right:0;border-bottom:1px solid var(--line)}main{margin-left:0;padding:16px}}
263
+ </style></head><body><nav><h4>Sections</h4><div id="toc"></div><h4 style="margin-top:18px">Source</h4><a href="daily-management-report.md">Markdown</a></nav><main id="content">Loading…</main>
264
+ <script src="marked.min.js"></script><script>
265
+ fetch('daily-management-report.md',{cache:'no-store'}).then(r=>r.text()).then(md=>{const el=document.getElementById('content');el.innerHTML=marked.parse(md);const toc=document.getElementById('toc');let i=0;el.querySelectorAll('h1,h2').forEach(h=>{h.id='s'+(i++);if(h.tagName==='H2'){const a=document.createElement('a');a.href='#'+h.id;a.textContent=h.textContent;toc.appendChild(a)}})});
266
+ </script></body></html>`;
267
+ }
268
+
269
+ // Serves the report Markdown as a browsable page on loopback. Only the page,
270
+ // its script and the Markdown are reachable; the evidence folder is not.
271
+ export async function reportServe(options) {
272
+ const file = path.resolve(options.adminFile || "daily-management-report.md");
273
+ if (!fs.existsSync(file)) throw new Error(`Report not found: ${file}`);
274
+ const title =
275
+ (fs.readFileSync(file, "utf8").match(/^#\s+(.+)$/m) || [])[1] ||
276
+ "Daily management report";
277
+ const port = Number(options.adminPort || 0);
278
+ const routes = {
279
+ "/": () => ({ type: "text/html; charset=utf-8", body: reportHtml(title) }),
280
+ "/index.html": () => ({ type: "text/html; charset=utf-8", body: reportHtml(title) }),
281
+ "/marked.min.js": () => ({ type: "text/javascript", body: fs.readFileSync(MARKED_ASSET) }),
282
+ "/daily-management-report.md": () => ({
283
+ type: "text/markdown; charset=utf-8",
284
+ body: fs.readFileSync(file),
285
+ }),
286
+ };
287
+ if (options.adminForeground) {
288
+ const server = http.createServer((req, res) => {
289
+ const route = routes[(req.url || "/").split("?")[0]];
290
+ if (!route) {
291
+ res.writeHead(404).end("not found");
292
+ return;
293
+ }
294
+ const { type, body } = route();
295
+ res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
296
+ res.end(body);
297
+ });
298
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
299
+ const url = `http://127.0.0.1:${server.address().port}/`;
300
+ console.log(JSON.stringify({ ok: true, status: "success", data: { url, file } }));
301
+ return new Promise(() => {});
302
+ }
303
+ // Detach a foreground copy of this command so the page outlives the CLI.
304
+ const child = spawn(
305
+ process.execPath,
306
+ [LUMINE_BIN, "admin", "report", "serve", "--file", file, "--port", String(port), "--foreground"],
307
+ { detached: true, stdio: ["ignore", "pipe", "ignore"] },
308
+ );
309
+ const url = await new Promise((resolve, reject) => {
310
+ let buf = "";
311
+ const timer = setTimeout(() => reject(new Error("report server did not start")), 10000);
312
+ child.stdout.on("data", (chunk) => {
313
+ buf += chunk;
314
+ const line = buf.split("\n")[0];
315
+ try {
316
+ const parsed = JSON.parse(line);
317
+ clearTimeout(timer);
318
+ resolve(parsed.data.url);
319
+ } catch {
320
+ /* wait for the full line */
321
+ }
322
+ });
323
+ child.on("exit", (code) => {
324
+ clearTimeout(timer);
325
+ reject(new Error(`report server exited (${code})`));
326
+ });
327
+ });
328
+ child.stdout.destroy();
329
+ child.unref();
330
+ if (options.adminOpen) {
331
+ spawnSync("open", ["-a", "Google Chrome", url]);
332
+ }
333
+ return {
334
+ ok: true,
335
+ status: "success",
336
+ data: { url, file, pid: child.pid, opened: Boolean(options.adminOpen) },
337
+ };
338
+ }