@saadnvd1/agent-os 0.3.792 → 0.3.799

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.
Files changed (75) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/tips/route.test.ts +56 -0
  3. package/app/api/tips/route.ts +45 -0
  4. package/app/api/updates/health/route.ts +22 -0
  5. package/app/api/updates/route.test.ts +53 -0
  6. package/app/api/updates/settings/route.ts +37 -10
  7. package/components/Chat/ChatPanel.tsx +28 -0
  8. package/components/Chat/needs-more-history.test.ts +28 -0
  9. package/components/Chat/needs-more-history.ts +14 -0
  10. package/components/Recipes/TimerFields.tsx +2 -1
  11. package/components/Schedules/ScheduleForm.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/components/SidebarFooter.tsx +2 -0
  14. package/components/Tips/TipButton.tsx +39 -0
  15. package/components/Tips/TipDialog.tsx +148 -0
  16. package/components/Tips/TipHost.tsx +14 -0
  17. package/components/Tips/TipMedia.tsx +71 -0
  18. package/components/Tips/index.ts +2 -0
  19. package/components/Updates/AutoUpdates.tsx +141 -0
  20. package/components/Updates/UpdateBanner.test.ts +39 -1
  21. package/components/Updates/UpdateBanner.tsx +31 -5
  22. package/components/Updates/UpdatesSection.tsx +24 -3
  23. package/content/tips.json +456 -0
  24. package/content/whats-new.json +32 -0
  25. package/data/tips/index.ts +49 -0
  26. package/data/updates/index.ts +5 -0
  27. package/hooks/useReloadWhenUpdated.ts +5 -1
  28. package/lib/busy.ts +23 -1
  29. package/lib/chat/drivers/copilot-approvals.ts +2 -0
  30. package/lib/chat/drivers/copilot-ask.ts +232 -0
  31. package/lib/chat/drivers/copilot-mapper.ts +9 -0
  32. package/lib/chat/drivers/copilot.ts +18 -4
  33. package/lib/db/migrations.ts +21 -0
  34. package/lib/recipes/timer.ts +3 -1
  35. package/lib/schedules/cron.ts +14 -5
  36. package/lib/schedules/store.ts +3 -2
  37. package/lib/security/demo.ts +2 -0
  38. package/lib/tasks/finish.ts +5 -5
  39. package/lib/tips/conditions.ts +38 -0
  40. package/lib/tips/index.ts +2 -0
  41. package/lib/tips/load.ts +26 -0
  42. package/lib/tips/media.ts +101 -0
  43. package/lib/tips/pick.ts +35 -0
  44. package/lib/tips/schema.ts +82 -0
  45. package/lib/tips/state.ts +26 -0
  46. package/lib/tips/view.ts +60 -0
  47. package/lib/updates/auto.ts +149 -0
  48. package/lib/updates/banner.ts +78 -3
  49. package/lib/updates/check.ts +64 -8
  50. package/lib/updates/history.ts +99 -0
  51. package/lib/updates/run.ts +334 -23
  52. package/lib/updates/settings.ts +66 -0
  53. package/lib/updates/view.ts +14 -3
  54. package/package.json +2 -2
  55. package/public/tips/.gitkeep +0 -0
  56. package/public/tips/chat-slash-menu-poster.webp +0 -0
  57. package/public/tips/chat-slash-menu.mp4 +0 -0
  58. package/scripts/autodeploy +4 -0
  59. package/scripts/autodeploy.test.ts +12 -0
  60. package/scripts/docs-links.test.ts +1 -25
  61. package/scripts/lib/commands.sh +32 -1
  62. package/scripts/lib/deps-swap.sh +8 -6
  63. package/scripts/lib/heading-slugs.ts +26 -0
  64. package/scripts/lib/update-verify.mjs +90 -0
  65. package/scripts/redeploy +31 -2
  66. package/scripts/redeploy.test.ts +94 -3
  67. package/scripts/tips-lint.test.ts +77 -0
  68. package/scripts/tips-media.ts +31 -0
  69. package/scripts/update-e2e.test.ts +215 -0
  70. package/scripts/update-recover.mjs +130 -0
  71. package/scripts/update-recover.test.ts +144 -0
  72. package/scripts/update-run.mjs +182 -40
  73. package/scripts/update-run.test.ts +208 -2
  74. package/server.ts +3 -2
  75. package/stores/tips.ts +14 -0
@@ -0,0 +1,232 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
7
+ import { z } from "zod";
8
+ import type { ChatQuestion } from "../events";
9
+ import {
10
+ answersInOrder,
11
+ type ApprovalOutcome,
12
+ type PendingApprovals,
13
+ } from "./pending-approvals";
14
+
15
+ type P = Record<string, unknown>;
16
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
17
+
18
+ export const ASK_SERVER = "agentos";
19
+ export const ASK_TOOL = "ask_user";
20
+ const MAX_BODY = 64 * 1024;
21
+
22
+ // Copilot CLI doesn't offer its own ask_user over ACP, so each conversation
23
+ // serves one: a question the agent asks becomes the chat's question card.
24
+ export const askInput = {
25
+ question: z.string().min(1).describe("The question to show the user"),
26
+ options: z
27
+ .array(
28
+ z.object({
29
+ label: z.string().min(1),
30
+ description: z.string().optional(),
31
+ })
32
+ )
33
+ .optional()
34
+ .describe("Choices to offer; leave out for a free-text question"),
35
+ multiSelect: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Let the user pick more than one option"),
39
+ allowFreeText: z
40
+ .boolean()
41
+ .optional()
42
+ .describe("The user can always type their own answer too"),
43
+ header: z.string().optional().describe("A short label for the question"),
44
+ };
45
+
46
+ type AskArgs = {
47
+ question: string;
48
+ options?: { label: string; description?: string }[];
49
+ multiSelect?: boolean;
50
+ header?: string;
51
+ };
52
+
53
+ export function askUserQuestion(a: AskArgs): ChatQuestion {
54
+ const seen = new Set<string>();
55
+ const options = (a.options ?? []).flatMap((o) => {
56
+ const label = o.label.trim();
57
+ if (!label || seen.has(label)) return [];
58
+ seen.add(label);
59
+ return [{ label, description: o.description?.trim() ?? "" }];
60
+ });
61
+ return {
62
+ question: a.question.trim(),
63
+ header: a.header?.trim() ?? "",
64
+ multiSelect: !!a.multiSelect && options.length > 0,
65
+ options,
66
+ };
67
+ }
68
+
69
+ const text = (t: string, isError = false): CallToolResult => ({
70
+ content: [{ type: "text", text: t }],
71
+ ...(isError ? { isError } : {}),
72
+ });
73
+
74
+ export function askUserResult(
75
+ q: ChatQuestion,
76
+ o: ApprovalOutcome
77
+ ): CallToolResult {
78
+ if (o.decision === "answer") {
79
+ const [list] = answersInOrder([q], o.answers);
80
+ return text(
81
+ JSON.stringify(
82
+ q.multiSelect ? { answers: list } : { answer: list[0] ?? "" }
83
+ )
84
+ );
85
+ }
86
+ if (o.decision === "expired")
87
+ return text("The question was cancelled before the user answered.", true);
88
+ return text("The user skipped this question without answering.");
89
+ }
90
+
91
+ // Whether a Copilot tool call (a tool_call update or a permission request) is
92
+ // our ask_user. Its title carries the server and tool name; the input is the
93
+ // fallback for a title that doesn't.
94
+ export function isAskUserCall(call: P): boolean {
95
+ const raw = (call.rawInput ?? {}) as P;
96
+ const title = str(call.title);
97
+ return (
98
+ title.includes(ASK_TOOL) ||
99
+ (title.includes(ASK_SERVER) && typeof raw.question === "string")
100
+ );
101
+ }
102
+
103
+ // One private MCP server per conversation: loopback only, behind a secret
104
+ // path, so nothing else can reach it or answer its cards.
105
+ export class CopilotAskServer {
106
+ private http?: http.Server;
107
+ private secret = crypto.randomBytes(32).toString("hex");
108
+ private next = 0;
109
+ private live = new Set<{ close(): Promise<void> }>();
110
+
111
+ constructor(private pending: PendingApprovals) {}
112
+
113
+ private build(): McpServer {
114
+ const server = new McpServer({ name: ASK_SERVER, version: "1.0.0" });
115
+ server.registerTool(
116
+ ASK_TOOL,
117
+ {
118
+ description:
119
+ "Ask the user a question and wait for the answer. Use it when you need a decision or missing information instead of asking in plain text. Offer options for a choice (multiSelect to allow several); with no options the user types an answer. Blocks until they answer.",
120
+ inputSchema: askInput,
121
+ },
122
+ async (args) => {
123
+ const q = askUserQuestion(args);
124
+ const o = await this.pending.ask({
125
+ id: `approval-ask-${++this.next}`,
126
+ toolName: "AskUserQuestion",
127
+ title: "Questions",
128
+ input: { questions: [q] },
129
+ questions: [q],
130
+ canAlways: false,
131
+ });
132
+ return askUserResult(q, o);
133
+ }
134
+ );
135
+ return server;
136
+ }
137
+
138
+ private allowed(req: http.IncomingMessage): number {
139
+ if (req.headers.origin) return 403;
140
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
141
+ if (host !== "127.0.0.1" && host !== "localhost") return 403;
142
+ const want = Buffer.from(`/mcp/${this.secret}`);
143
+ const got = Buffer.from((req.url ?? "").split("?")[0]);
144
+ return got.length === want.length && crypto.timingSafeEqual(got, want)
145
+ ? 200
146
+ : 404;
147
+ }
148
+
149
+ private async handle(
150
+ req: http.IncomingMessage,
151
+ res: http.ServerResponse
152
+ ): Promise<void> {
153
+ const status = this.allowed(req);
154
+ if (status !== 200) {
155
+ res.writeHead(status).end();
156
+ return;
157
+ }
158
+ if (req.method !== "POST") {
159
+ res.writeHead(405, { allow: "POST" }).end();
160
+ return;
161
+ }
162
+ let size = 0;
163
+ const chunks: Buffer[] = [];
164
+ for await (const c of req) {
165
+ size += (c as Buffer).length;
166
+ if (size > MAX_BODY) {
167
+ res.writeHead(413).end();
168
+ return;
169
+ }
170
+ chunks.push(c as Buffer);
171
+ }
172
+ let body: unknown;
173
+ try {
174
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
175
+ } catch {
176
+ res.writeHead(400).end();
177
+ return;
178
+ }
179
+ const server = this.build();
180
+ const transport = new StreamableHTTPServerTransport({
181
+ sessionIdGenerator: undefined,
182
+ enableJsonResponse: true,
183
+ });
184
+ const handle = {
185
+ close: async () => {
186
+ await transport.close();
187
+ await server.close();
188
+ },
189
+ };
190
+ this.live.add(handle);
191
+ res.on("close", () => {
192
+ this.live.delete(handle);
193
+ void handle.close().catch(() => {});
194
+ });
195
+ await server.connect(transport);
196
+ await transport.handleRequest(req, res, body);
197
+ }
198
+
199
+ // The `mcpServers` entry to give ACP session/new and session/load.
200
+ async start(): Promise<P> {
201
+ const server = http.createServer((req, res) => {
202
+ this.handle(req, res).catch(() => {
203
+ if (!res.headersSent) res.writeHead(500);
204
+ res.end();
205
+ });
206
+ });
207
+ await new Promise<void>((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(0, "127.0.0.1", resolve);
210
+ });
211
+ this.http = server;
212
+ const { port } = server.address() as AddressInfo;
213
+ return {
214
+ type: "http",
215
+ name: ASK_SERVER,
216
+ url: `http://127.0.0.1:${port}/mcp/${this.secret}`,
217
+ headers: [],
218
+ };
219
+ }
220
+
221
+ // Cancels any question still waiting (its call returns a cancellation)
222
+ // and stops listening.
223
+ close(): void {
224
+ this.pending.expireAll();
225
+ const server = this.http;
226
+ this.http = undefined;
227
+ server?.close();
228
+ server?.closeAllConnections();
229
+ for (const h of this.live) void h.close().catch(() => {});
230
+ this.live.clear();
231
+ }
232
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ChatContext, DriverEvent, UsageTotals } from "../events";
2
2
  import { toolTitle } from "../tools";
3
+ import { isAskUserCall } from "./copilot-ask";
3
4
  import { TurnItems, type ToolStart } from "./turn-items";
4
5
 
5
6
  type P = Record<string, unknown>;
@@ -135,14 +136,22 @@ export class CopilotMapper {
135
136
  this.block++;
136
137
  }
137
138
 
139
+ // The card is the question's only trace, so its tool call stays hidden.
140
+ private asks = new Set<string>();
141
+
138
142
  private started(update: P): void {
139
143
  this.items.startTurn();
144
+ if (isAskUserCall(update)) {
145
+ this.asks.add(str(update.toolCallId));
146
+ return;
147
+ }
140
148
  this.close();
141
149
  this.items.tool(str(update.toolCallId), toolStart(update));
142
150
  }
143
151
 
144
152
  private updated(update: P): void {
145
153
  const id = str(update.toolCallId);
154
+ if (this.asks.has(id)) return;
146
155
  const status = str(update.status);
147
156
  if (status !== "completed" && status !== "failed") return;
148
157
  const start = toolStart(update);
@@ -6,6 +6,7 @@ import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
6
6
  import type { ChatImage, ChatModel, DriverEvent } from "../events";
7
7
  import { InputQueue } from "../queue";
8
8
  import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
9
+ import { ASK_SERVER, ASK_TOOL, CopilotAskServer } from "./copilot-ask";
9
10
  import { CopilotApprovals } from "./copilot-approvals";
10
11
  import { CopilotMapper } from "./copilot-mapper";
11
12
  import { CopilotRpc } from "./copilot-rpc";
@@ -33,7 +34,10 @@ export function copilotArgs(
33
34
  fallback = copilotDefaultModel({ ...process.env, ...o.env })
34
35
  ): string[] {
35
36
  const model = o.model && o.model !== AGENT_DEFAULT_MODEL ? o.model : fallback;
36
- return model ? ["--model", model] : [];
37
+ return [
38
+ ...(model ? ["--model", model] : []),
39
+ `--allow-tool=${ASK_SERVER}(${ASK_TOOL})`,
40
+ ];
37
41
  }
38
42
 
39
43
  // The models a new ACP session offers (`models.availableModels`), with
@@ -117,6 +121,7 @@ export const copilotDriver: ChatDriver = {
117
121
  const emit = (e: DriverEvent) => out.push(e);
118
122
  const mapper = new CopilotMapper(emit);
119
123
  const approvals = new CopilotApprovals(emit, options.access);
124
+ const ask = new CopilotAskServer(approvals.pending);
120
125
  let sessionId: string | null = null;
121
126
  let busy = false;
122
127
  let intro = options.resumeId ? "" : (options.systemAppend ?? "").trim();
@@ -136,7 +141,7 @@ export const copilotDriver: ChatDriver = {
136
141
  void approvals.handle(params).then((result) => rpc.reply(id, result));
137
142
  },
138
143
  exit(error) {
139
- approvals.pending.expireAll();
144
+ ask.close();
140
145
  mapper.endTurn({ error });
141
146
  out.end();
142
147
  },
@@ -145,7 +150,15 @@ export const copilotDriver: ChatDriver = {
145
150
  emit({ type: "usage_start", totals: { ...mapper.totals } });
146
151
 
147
152
  const ready = rpc.initialize().then(async () => {
148
- const params = { cwd: options.cwd, mcpServers: [] };
153
+ // Without it the agent just can't ask: the chat still works.
154
+ const mcpServers = await ask.start().then(
155
+ (s) => [s],
156
+ (e: Error) => {
157
+ mapper.items.error(`Questions aren't available: ${e.message}`);
158
+ return [];
159
+ }
160
+ );
161
+ const params = { cwd: options.cwd, mcpServers };
149
162
  if (options.resumeId) {
150
163
  mapper.replaying = true;
151
164
  try {
@@ -170,6 +183,7 @@ export const copilotDriver: ChatDriver = {
170
183
  emit({ type: "resume_id", id: sessionId });
171
184
  });
172
185
  ready.catch((error: Error) => {
186
+ ask.close();
173
187
  mapper.items.error(`GitHub Copilot couldn't start: ${error.message}`);
174
188
  mapper.endTurn({});
175
189
  out.end();
@@ -243,7 +257,7 @@ export const copilotDriver: ChatDriver = {
243
257
  };
244
258
  },
245
259
  close() {
246
- approvals.pending.expireAll();
260
+ ask.close();
247
261
  rpc.close();
248
262
  out.end();
249
263
  },
@@ -26,6 +26,11 @@ export type { Migration } from "./migration-types";
26
26
  // typecheck, lint and test) and is not committed. A duplicate id or key, or a
27
27
  // file whose name does not match its id, throws when this module loads.
28
28
  // lib/db/migrations.test.ts enforces the rest.
29
+ //
30
+ // A migration must leave the previous release runnable: an automatic update
31
+ // that isn't healthy rolls the code back but not the database (chat workers
32
+ // keep writing through the restart, so a restore would drop their work). Add
33
+ // columns and tables; don't rename or drop what the last release reads.
29
34
 
30
35
  export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
31
36
  const migrationName = (m: Migration): string =>
@@ -93,6 +98,22 @@ function compareToUpTo(m: Migration, upTo: number | string): number {
93
98
  // All migrations in order - never modify existing ones, only add new
94
99
  export const migrations: Migration[] = buildRegistry(migrationFiles);
95
100
 
101
+ /** How many of this build's migrations the database has; the update health check reads it. */
102
+ export function migrationStatus(db: Database.Database): {
103
+ known: number;
104
+ applied: number;
105
+ } {
106
+ const rows = db.prepare(`SELECT id, key FROM _migrations`).all() as {
107
+ id: number;
108
+ key: string | null;
109
+ }[];
110
+ const have = new Set(rows.map((r) => r.key ?? String(r.id)));
111
+ return {
112
+ known: migrations.length,
113
+ applied: migrations.filter((m) => have.has(migrationKey(m))).length,
114
+ };
115
+ }
116
+
96
117
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
97
118
  // start from an older database).
98
119
  export function runMigrations(
@@ -67,7 +67,9 @@ export function setTimer(
67
67
  if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
68
68
  const bad = cronError(cron);
69
69
  if (bad) throw new Error(bad);
70
- if (minGapMinutes(cron, timezone) < MIN_GAP_MINUTES)
70
+ if (
71
+ minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
72
+ )
71
73
  throw new Error("A recipe runs at most once an hour");
72
74
  const enabled = input.enabled ?? true;
73
75
  if (!existing) {
@@ -261,16 +261,25 @@ export const MIN_GAP_MINUTES = 60;
261
261
  // A message to a session, at most this often.
262
262
  export const MESSAGE_MIN_GAP_MINUTES = 10;
263
263
 
264
- // The shortest gap between runs over the next few weeks of them.
264
+ // The shortest gap between runs over the next few weeks of them. With
265
+ // `stopBelow`, it returns as soon as a gap under it turns up: "every minute"
266
+ // then costs two searches, not two hundred.
265
267
  export function minGapMinutes(
266
268
  cron: string,
267
269
  timezone: string,
268
- from = Date.now()
270
+ from = Date.now(),
271
+ stopBelow = 0
269
272
  ): number {
270
- const runs = nextRuns(cron, from, 200, timezone);
273
+ const parsed = parseCron(cron);
271
274
  let min = Infinity;
272
- for (let i = 1; i < runs.length; i++)
273
- min = Math.min(min, (runs[i] - runs[i - 1]) / 60_000);
275
+ let prev = nextRun(parsed, from, timezone);
276
+ for (let i = 1; i < 200 && prev !== null; i++) {
277
+ const next = nextRun(parsed, prev, timezone);
278
+ if (next === null) break;
279
+ min = Math.min(min, (next - prev) / 60_000);
280
+ if (min < stopBelow) break;
281
+ prev = next;
282
+ }
274
283
  return min;
275
284
  }
276
285
 
@@ -298,7 +298,7 @@ function validate(
298
298
  if (
299
299
  !oneOff &&
300
300
  (input.kind === "task" || input.kind === "session") &&
301
- minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
301
+ minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
302
302
  )
303
303
  throw new Error(
304
304
  "A task or session schedule runs at most once an hour; for more often, post to the orchestrator"
@@ -309,7 +309,8 @@ function validate(
309
309
  if (
310
310
  !oneOff &&
311
311
  (message || agentCheckIn) &&
312
- minGapMinutes(cron, timezone) < MESSAGE_MIN_GAP_MINUTES
312
+ minGapMinutes(cron, timezone, Date.now(), MESSAGE_MIN_GAP_MINUTES) <
313
+ MESSAGE_MIN_GAP_MINUTES
313
314
  )
314
315
  throw new Error(
315
316
  `A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
@@ -150,6 +150,8 @@ const READS = [
150
150
  "demo",
151
151
  // The changelog: a static file, no agent.
152
152
  "whats-new",
153
+ // Tip of the Day: reading is a static file plus two settings.
154
+ "tips",
153
155
  ].map(route);
154
156
 
155
157
  // The only writes, each a database write and nothing more in demo mode:
@@ -88,17 +88,17 @@ async function cleanup(
88
88
  forgetPR(session.id);
89
89
  }
90
90
 
91
- // Sign-offs between `gh pr merge` and the database knowing it, so the
92
- // watcher doesn't read our own merge as one done on GitHub.
93
- export const signingOff = new Set<string>();
94
-
95
91
  // What happens after a merge: restack anything stacked on it, then remove the
96
92
  // session, worktree and branch. Kept per task so Land and done can wait for
97
93
  // it; on globalThis because the watcher's merges run in server.ts and done in
98
- // a route bundle, which load this module separately.
94
+ // a route bundle, which load this module separately. lib/busy.ts counts both.
99
95
  const g = globalThis as unknown as {
100
96
  __agentosAfterMerge?: Map<string, Promise<void>>;
97
+ __agentosSigningOff?: Set<string>;
101
98
  };
99
+ // Sign-offs between `gh pr merge` and the database knowing it, so the
100
+ // watcher doesn't read our own merge as one done on GitHub.
101
+ export const signingOff: Set<string> = (g.__agentosSigningOff ??= new Set());
102
102
  const afterMerge: Map<string, Promise<void>> = (g.__agentosAfterMerge ??=
103
103
  new Map());
104
104
 
@@ -0,0 +1,38 @@
1
+ import { readConnect } from "@/lib/connect/config";
2
+ import { listBookmarks } from "@/lib/bookmarks/store";
3
+ import { listRecipes } from "@/lib/recipes/store";
4
+ import { listSchedules } from "@/lib/schedules/store";
5
+ import { listDevices } from "@/lib/security/devices";
6
+ import { listGroups } from "@/lib/session-groups/store";
7
+ import { listSharedLinks } from "@/lib/share/store";
8
+ import type { Condition, Tip } from "./schema";
9
+
10
+ const CHECKS: Record<Condition, () => boolean> = {
11
+ "no-paired-device": () => listDevices().length === 0,
12
+ "no-push-device": () => !listDevices().some((d) => d.push.enabled),
13
+ "connect-not-linked": () => !readConnect().ok,
14
+ "connect-linked": () => {
15
+ const c = readConnect();
16
+ return c.ok && c.enabled;
17
+ },
18
+ "no-schedules": () => listSchedules().length === 0,
19
+ "no-recipes": () => listRecipes().length === 0,
20
+ "no-shares": () => listSharedLinks().length === 0,
21
+ "no-groups": () => listGroups().length === 0,
22
+ "no-bookmarks": () =>
23
+ listBookmarks({ workspace: "all", limit: 1 }).bookmarks.length === 0,
24
+ };
25
+
26
+ /** Whether the tip applies now; a check that throws counts as no. */
27
+ export function tipApplies(
28
+ tip: Pick<Tip, "when">,
29
+ checks: Record<string, () => boolean> = CHECKS
30
+ ): boolean {
31
+ return (tip.when ?? []).every((c) => {
32
+ try {
33
+ return checks[c]?.() ?? false;
34
+ } catch {
35
+ return false;
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,2 @@
1
+ export { markSeen, setTipsOff, tipsView } from "./view";
2
+ export type { ClientTip, Media, Tip, TipsView } from "./schema";
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileSchema, type Tip } from "./schema";
4
+
5
+ export const TIPS_FILE = "content/tips.json";
6
+
7
+ let cached: { file: string; mtime: number; tips: Tip[] } | null = null;
8
+
9
+ /** Every tip that isn't retired. A missing or unreadable file is empty. */
10
+ export function loadTips(root: string = process.cwd()): Tip[] {
11
+ const file = path.join(root, TIPS_FILE);
12
+ try {
13
+ const mtime = fs.statSync(file).mtimeMs;
14
+ if (cached?.file === file && cached.mtime === mtime) return cached.tips;
15
+ const parsed = fileSchema.safeParse(
16
+ JSON.parse(fs.readFileSync(file, "utf-8"))
17
+ );
18
+ const tips = parsed.success
19
+ ? parsed.data.tips.filter((t) => !t.hidden)
20
+ : [];
21
+ cached = { file, mtime, tips };
22
+ return tips;
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileSchema, type Media, type MediaBrief, type Tip } from "./schema";
4
+
5
+ export const MEDIA_DIR = "public/tips";
6
+ export const BUDGET = {
7
+ still: 90 * 1024,
8
+ clip: 500 * 1024,
9
+ poster: 40 * 1024,
10
+ total: 6 * 1024 * 1024,
11
+ clipSeconds: 8,
12
+ width: 768,
13
+ } as const;
14
+
15
+ /** The files a media value points at, as names inside public/tips. */
16
+ export function mediaFiles(m: Media): { name: string; max: number }[] {
17
+ const name = (src: string) => path.basename(src);
18
+ return m.kind === "image"
19
+ ? [
20
+ { name: name(m.src), max: BUDGET.still },
21
+ ...(m.srcDark ? [{ name: name(m.srcDark), max: BUDGET.still }] : []),
22
+ ]
23
+ : [
24
+ { name: name(m.src), max: BUDGET.clip },
25
+ { name: name(m.poster), max: BUDGET.poster },
26
+ ];
27
+ }
28
+
29
+ export interface ManifestRow {
30
+ id: string;
31
+ title: string;
32
+ kind: MediaBrief["kind"];
33
+ shot: string;
34
+ theme: NonNullable<MediaBrief["theme"]>;
35
+ guide?: string;
36
+ target: { width: number; maxBytes: number; maxSeconds?: number };
37
+ dest: string;
38
+ }
39
+
40
+ /** What Luna still has to make: tips with a brief and no media yet. */
41
+ export function manifest(tips: Tip[]): ManifestRow[] {
42
+ return tips
43
+ .filter((t) => t.mediaBrief && !t.media)
44
+ .map((t) => {
45
+ const b = t.mediaBrief!;
46
+ const clip = b.kind === "clip";
47
+ return {
48
+ id: t.id,
49
+ title: t.title,
50
+ kind: b.kind,
51
+ shot: b.shot,
52
+ theme: b.theme ?? (clip ? "dark" : "both"),
53
+ ...(t.guide && { guide: t.guide.file }),
54
+ target: {
55
+ width: BUDGET.width,
56
+ maxBytes: clip ? BUDGET.clip : BUDGET.still,
57
+ ...(clip && { maxSeconds: BUDGET.clipSeconds }),
58
+ },
59
+ dest: `${MEDIA_DIR}/${t.id}.${clip ? "mp4" : "webp"}`,
60
+ };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Copies the finished files from `from` into `root`/public/tips and writes each
66
+ * tip's `media` (dropping its brief). Throws before changing anything if an id
67
+ * is unknown, a file is missing, or a file is over its budget.
68
+ */
69
+ export function applyHandback(
70
+ root: string,
71
+ from: string,
72
+ handback: Record<string, Media>
73
+ ): string[] {
74
+ const file = path.join(root, "content/tips.json");
75
+ const parsed = fileSchema.parse(JSON.parse(fs.readFileSync(file, "utf-8")));
76
+ const copies: [string, string][] = [];
77
+ for (const [id, media] of Object.entries(handback)) {
78
+ if (!parsed.tips.some((t) => t.id === id))
79
+ throw new Error(`Unknown tip "${id}"`);
80
+ for (const { name, max } of mediaFiles(media)) {
81
+ const src = path.join(from, name);
82
+ if (!fs.existsSync(src)) throw new Error(`${id}: ${name} is missing`);
83
+ const size = fs.statSync(src).size;
84
+ if (size > max)
85
+ throw new Error(`${id}: ${name} is ${size} bytes, over ${max}`);
86
+ copies.push([src, path.join(root, MEDIA_DIR, name)]);
87
+ }
88
+ if (media.kind === "video" && media.durationMs > BUDGET.clipSeconds * 1000)
89
+ throw new Error(`${id}: clip is longer than ${BUDGET.clipSeconds}s`);
90
+ }
91
+ fs.mkdirSync(path.join(root, MEDIA_DIR), { recursive: true });
92
+ for (const [a, b] of copies) fs.copyFileSync(a, b);
93
+ const tips = parsed.tips.map((t) => {
94
+ const media = handback[t.id];
95
+ if (!media) return t;
96
+ const { mediaBrief: _brief, ...rest } = t;
97
+ return { ...rest, media };
98
+ });
99
+ fs.writeFileSync(file, JSON.stringify({ ...parsed, tips }, null, 2) + "\n");
100
+ return Object.keys(handback);
101
+ }
@@ -0,0 +1,35 @@
1
+ import type { Tip } from "./schema";
2
+
3
+ export interface Pinned {
4
+ day: string;
5
+ id: string;
6
+ read: boolean;
7
+ }
8
+
9
+ /** Newest first; tips added the same day keep the order they have in the file. */
10
+ export const orderDeck = <T extends Pick<Tip, "id" | "added">>(
11
+ tips: T[]
12
+ ): T[] =>
13
+ [...tips].sort((a, b) =>
14
+ a.added === b.added ? 0 : a.added < b.added ? 1 : -1
15
+ );
16
+
17
+ /**
18
+ * The tip for `day`: the one already chosen today while it still applies,
19
+ * else the first the user hasn't seen, else (all seen) the one seen longest ago.
20
+ */
21
+ export function pickToday(o: {
22
+ deck: { id: string }[];
23
+ seen: Record<string, string>;
24
+ pinned: Pinned | null;
25
+ day: string;
26
+ }): Pinned | null {
27
+ const { deck, seen, pinned, day } = o;
28
+ if (!deck.length) return null;
29
+ if (pinned?.day === day && deck.some((t) => t.id === pinned.id))
30
+ return pinned;
31
+ const fresh = deck.find((t) => !seen[t.id]);
32
+ if (fresh) return { day, id: fresh.id, read: false };
33
+ const oldest = deck.reduce((a, b) => (seen[b.id] < seen[a.id] ? b : a));
34
+ return { day, id: oldest.id, read: false };
35
+ }