@mulmoclaude/core 5.0.0 → 5.1.0

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.
@@ -400,11 +400,18 @@ quota / moderation rejection from that provider.
400
400
 
401
401
  The server appends the provider's own error to the message (e.g.
402
402
  `… — 401 Incorrect API key provided`) and logs it under the
403
- `mulmocast` prefix — read that detail first. Then either add the
404
- missing key to `.env` (restart the server) or rewrite the script's
405
- `imageParams` / `movieParams` / speaker providers to ones that have
406
- keys configured. Don't retry the render unchanged — the same provider
407
- will fail the same way.
403
+ `mulmocast` prefix — read that detail first. Then either supply the
404
+ missing key or rewrite the script's `imageParams` / `movieParams` /
405
+ speaker providers to ones that have keys configured. Don't retry the
406
+ render unchanged — the same provider will fail the same way.
407
+
408
+ For `GEMINI_API_KEY`, tell the user to open **Settings → Gemini** and
409
+ paste the key there: it takes effect immediately, with no restart and no
410
+ file to locate, and it wins over a stale value in the shell or a `.env`
411
+ (`config/helps/gemini.md`). The other providers' keys still come from a
412
+ `.env` and need a restart. A render started BEFORE the key was saved
413
+ keeps the environment it was spawned with, so re-run the render rather
414
+ than resuming the old one.
408
415
 
409
416
  ## MulmoScript narration — wrong voice, ignored direction, or every beat re-recorded
410
417
 
@@ -36,24 +36,29 @@ The Gemini API has a **free tier that is sufficient for personal use**. Higher-v
36
36
  1. Open [Google AI Studio → API keys](https://aistudio.google.com/apikey) and sign in with a Google account.
37
37
  2. Click **Create API key**. If prompted, select or create a Google Cloud project (any project will do).
38
38
  3. Copy the key — it starts with `AIza…`.
39
- 4. Create (or open) a `.env` file **in the directory you launch MulmoClaude from** — i.e. the directory where you run `npx mulmoclaude` (in a cloned repo, that's the repo root). Add the line:
39
+ 4. Open **Settings → Gemini**, paste the key into the field, and click Save. That is the whole step — it takes effect immediately, with no restart and no file to find.
40
40
 
41
- ```dotenv
42
- GEMINI_API_KEY=AIza…your-key…
43
- ```
41
+ The key is written to `~/.mulmoclaude/secrets/GEMINI_API_KEY`, readable only by your user account. It is deliberately outside the `~/mulmoclaude` workspace, which the assistant manages.
44
42
 
45
- The key stays in your launch directory, not in the `~/mulmoclaude` workspace (which is managed by the assistant). You can also just `export GEMINI_API_KEY=…` in your shell before launching — an exported value takes precedence over the `.env` file.
43
+ ### The `.env` route (still supported)
46
44
 
47
- 5. Restart MulmoClaude so the new environment variable is picked up.
45
+ A key in a `.env` file keeps working, and is the right choice for a scripted or CI setup. Add `GEMINI_API_KEY=AIza…` to:
46
+
47
+ - **Started from the icon** (Finder / desktop shortcut): `~/.env`, in your home directory. An icon has no launch directory — macOS starts apps in `/` — so the app is started from home instead. A shell `export` does **not** work on this route: the launcher takes PATH from your login shell and nothing else, so nothing in `~/.zshrc` reaches the app.
48
+ - **Started from a terminal** (`npx mulmoclaude`, or `yarn dev` in a clone): the directory you ran the command in. Here `export GEMINI_API_KEY=…` before launching works too.
49
+
50
+ Either file needs a restart to be picked up — from the icon, stop the server first via Settings → SERVER → Quit, then click the icon again.
51
+
52
+ **A key saved in Settings wins** over both the file and the shell. Settings → Gemini says which of the two is in effect, so a value you cannot find is still accounted for.
48
53
 
49
54
  ## Verifying It's Active
50
55
 
51
- The quickest check: switch to the **Artist** role and ask for _"an image of a red panda"_. If a real image appears in the canvas (instead of an italic text marker or a disabled-role hint), the key is wired up correctly.
56
+ Settings → Gemini states whether a key is configured and where it came from. For an end-to-end check, switch to the **Artist** role and ask for _"an image of a red panda"_: if a real image appears in the canvas (instead of an italic text marker or a disabled-role hint), the key is wired up correctly.
52
57
 
53
- You can also inspect the server log on startup: a `GEMINI_API_KEY not set — image / audio / video generation is unavailable` warning means the key was not found (missing, misread, or placed outside your launch directory).
58
+ You can also inspect the server log on startup: a `GEMINI_API_KEY not set — image / audio / video generation is unavailable` warning means the key was not found. The warning names the exact `.env` path this launch reads, so a key that is set but in the wrong file shows up as a path you did not expect.
54
59
 
55
60
  ## Security
56
61
 
57
- - The key lives in your local `.env` file. MulmoClaude never uploads it to its own servers or to Anthropic — requests go directly from your machine to Google.
62
+ - The key stays on your machine — in `~/.mulmoclaude/secrets/` when saved from Settings, or in your own `.env` file. MulmoClaude never uploads it to its own servers or to Anthropic; requests go directly from your machine to Google.
58
63
  - Treat the key like a password. Anyone who sees it can make billable API calls against your Google account.
59
64
  - If you suspect a key has leaked, revoke it from [Google AI Studio → API keys](https://aistudio.google.com/apikey) and generate a new one.
@@ -106,6 +106,39 @@ Declaring it in `map` is a schema error.
106
106
  Use `datetime` (not `date`) for start/end when events have real clock times —
107
107
  the calendar day view then draws each record as a proportional time block.
108
108
 
109
+ ## All-day events
110
+
111
+ A `datetime` column stores an all-day event as `2026-07-17T00:00`, because that
112
+ is where the day view places it. That is fine for mirroring, but it is also
113
+ exactly how a real midnight appointment is stored — so a record CREATED locally
114
+ in a `datetime` column is pushed as a midnight event, never as an all-day one.
115
+
116
+ For a calendar whose events are all-day, give start/end a **`date`** column
117
+ instead. Google's bare date is then kept verbatim, and a record the user creates
118
+ by typing two dates is pushed as a real all-day event.
119
+
120
+ ```jsonc
121
+ "fields": {
122
+ "on": { "type": "date", "label": "From" },
123
+ "until": { "type": "date", "label": "To" }
124
+ },
125
+ "googleCalendar": { "map": { "on": "start", "until": "end" } }
126
+ ```
127
+
128
+ Google's all-day `end` is **exclusive** — it is the day AFTER the last day, so a
129
+ single day on the 17th is `on: 2026-07-17`, `until: 2026-07-18`. Records
130
+ mirrored from Google already carry it that way. Say so when the user asks why
131
+ the end date "looks a day late"; do not offset it, because the push sends the
132
+ stored value straight back.
133
+
134
+ An existing all-day event stays all-day when its dates are edited, whatever the
135
+ column type: the push reads what Google last reported for that event and keeps
136
+ its kind. Only a record with no such history — one created locally — depends on
137
+ the column type.
138
+
139
+ To create a one-off all-day event without a collection, use the `google` tool's
140
+ `calendarCreateEvent` with a bare date on both ends.
141
+
109
142
  ## When sync runs
110
143
 
111
144
  - **On creation** — the first sync starts as soon as the schema lands, so the
@@ -174,9 +207,11 @@ What the button does and deliberately does not do:
174
207
  Reasons a record can be reported as skipped:
175
208
 
176
209
  - **Its record id cannot be a Google event id.** Google requires 5-1024
177
- characters from `0-9a-v`. Records created through the UI get a valid
178
- generated id; a semantic id you authored (`team-standup`) cannot be used. Fix
179
- by recreating the record without setting the primary field.
210
+ characters from `0-9a-v` (lower-case base32hex: digits plus `a`-`v`, so no
211
+ `w`-`z`, no upper case, no hyphen). Records created through the UI get a
212
+ valid generated id; a semantic id you authored (`team-standup`) cannot be
213
+ used. Fix by recreating the record without setting the primary field, or by
214
+ choosing an id that satisfies the rule.
180
215
  - **No `start` / `end` is mapped, on a record being CREATED.** An event cannot
181
216
  be created without a span. Editing an existing event is unaffected — a changed
182
217
  title or colour is patched on its own.
@@ -32,13 +32,23 @@ from `calendarListCalendars` — to target another.
32
32
  | `calendarColors` | Palettes mapping a `colorId` to hex, for events and for calendars |
33
33
  | `calendarListEvents` | Upcoming events. Optional `calendarId`, `timeMin`, `maxResults` (1-50, default 10) |
34
34
  | `calendarSync` | Only what CHANGED since the last sync, via a stored token. Returns counts plus a capped sample |
35
- | `calendarCreateEvent` | Create. Requires `summary`, `start`, `end`; optional `description`, `calendarId`, `colorId` |
35
+ | `calendarCreateEvent` | Create, timed or all-day. Requires `summary`, `start`, `end`; optional `description`, `calendarId`, `colorId` |
36
36
  | `calendarUpdateEvent` | Edit in place. Requires `eventId` + at least one of `summary`, `start`, `end`, `description`, `colorId` |
37
37
  | `calendarDeleteEvent` | Delete. Requires `eventId` |
38
38
 
39
- **Date-times must carry a timezone offset** — `2026-07-17T09:00:00+09:00`, not
40
- `2026-07-17` and not `2026-07-17T09:00:00`. Calendar rejects the others with an
41
- opaque 400.
39
+ **A timed event's ends must carry a timezone offset** —
40
+ `2026-07-17T09:00:00+09:00`, not `2026-07-17T09:00:00`. Calendar rejects an
41
+ offset-less value with an opaque 400. The same goes for `timeMin`.
42
+
43
+ **An all-day event takes a bare date on BOTH ends** — `start: "2026-07-17"`,
44
+ `end: "2026-07-18"`. Its `end` is **exclusive**: it is the day AFTER the last
45
+ day, so a single day on the 17th ends on the 18th, and a three-day event
46
+ starting the 17th ends on the 20th. This is Google's own convention and the
47
+ values it reports back, so do not "correct" it.
48
+
49
+ One end of each kind is rejected. Editing an all-day event needs **both** ends —
50
+ a lone date is refused, because whether the stored event is all-day cannot be
51
+ told from the argument.
42
52
 
43
53
  **Editing is a patch.** Fields you omit keep their current value, so changing a
44
54
  title needs `eventId` + `summary` and nothing else. `description: ""` clears the
@@ -0,0 +1,58 @@
1
+ import { CalendarEventTime } from './calendar.js';
2
+ /** Whether `value` is a bare calendar day — the all-day spelling of an event
3
+ * time, and the one shape `isIsoDateTimeWithOffset` is built to refuse.
4
+ *
5
+ * Realness comes from the parser the record lint, the calendar grid and the
6
+ * push already share, so every surface agrees that `2026-02-30` is not a day.
7
+ * The regex runs first because that parser trims, and a value with whitespace
8
+ * around it would validate here and then reach Google verbatim. */
9
+ export declare const isCalendarDateOnly: (value: string) => boolean;
10
+ /** One end of a span, or null when it is neither shape Calendar accepts. */
11
+ export declare const toEventTimeInput: (value: string) => CalendarEventTime | null;
12
+ /** The rule every surface states for a value that is neither shape. Shared so
13
+ * the tool, the remote host and their tests cannot drift apart. */
14
+ export declare const EVENT_TIME_HINT = "must be an ISO 8601 date-time with a timezone offset (e.g. 2026-07-17T09:00:00+09:00), or a date for an all-day event (e.g. 2026-07-17)";
15
+ /** The same rule with the offending key named, for a surface whose error text
16
+ * carries no path of its own. */
17
+ export declare const eventTimeHint: (key: string) => string;
18
+ /** All-day `end` is exclusive, so a one-day event ends on the NEXT day. Spelled
19
+ * out because the natural reading of "all day on the 17th" is `end` = the
20
+ * 17th, which Calendar refuses. */
21
+ export declare const ALL_DAY_END_HINT = "an all-day `end` is EXCLUSIVE \u2014 it is the day AFTER the last day, so a single day on 2026-07-17 needs end 2026-07-18";
22
+ export declare const MIXED_SPAN_HINT = "start and end must both be date-times with an offset, or both be dates for an all-day event \u2014 not one of each";
23
+ /** A lone all-day end cannot be patched: the stored event's kind is unknown
24
+ * here, and giving one end of a timed event a `date` makes an event Calendar
25
+ * rejects — with a message that names neither end. Both together are
26
+ * unambiguous, so that is what the caller is asked for. */
27
+ export declare const LONE_ALL_DAY_HINT = "pass BOTH start and end to move an all-day event \u2014 an all-day `end` is EXCLUSIVE \u2014 it is the day AFTER the last day, so a single day on 2026-07-17 needs end 2026-07-18";
28
+ export interface SpanTimes {
29
+ start: CalendarEventTime;
30
+ end: CalendarEventTime;
31
+ }
32
+ export type SpanResult = {
33
+ ok: true;
34
+ span: SpanTimes;
35
+ } | {
36
+ ok: false;
37
+ reason: string;
38
+ };
39
+ /** Both ends of a create, validated together.
40
+ *
41
+ * Only the all-day ordering is checked: a timed span's ordering depends on the
42
+ * two offsets, which Calendar resolves and reports on clearly, while an all-day
43
+ * `end <= start` comes back as an opaque 400. Lexicographic comparison is exact
44
+ * for `YYYY-MM-DD` — fixed-width, most significant part first. */
45
+ export declare function resolveSpanInput(start: string, end: string): SpanResult;
46
+ export interface PartialSpanTimes {
47
+ start?: CalendarEventTime;
48
+ end?: CalendarEventTime;
49
+ }
50
+ export type PartialSpanResult = {
51
+ ok: true;
52
+ times: PartialSpanTimes;
53
+ } | {
54
+ ok: false;
55
+ reason: string;
56
+ };
57
+ /** The span of an EDIT, where either end may be absent. */
58
+ export declare function resolvePartialSpanInput(start: string | undefined, end: string | undefined): PartialSpanResult;
@@ -45,6 +45,104 @@ var isIsoDateTimeWithOffset = (value) => {
45
45
  return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;
46
46
  };
47
47
  //#endregion
48
+ //#region src/google/eventSpanInput.ts
49
+ var DATE_ONLY_RE$1 = /^\d{4}-\d{2}-\d{2}$/;
50
+ /** Whether `value` is a bare calendar day — the all-day spelling of an event
51
+ * time, and the one shape `isIsoDateTimeWithOffset` is built to refuse.
52
+ *
53
+ * Realness comes from the parser the record lint, the calendar grid and the
54
+ * push already share, so every surface agrees that `2026-02-30` is not a day.
55
+ * The regex runs first because that parser trims, and a value with whitespace
56
+ * around it would validate here and then reach Google verbatim. */
57
+ var isCalendarDateOnly = (value) => DATE_ONLY_RE$1.test(value) && require_itemId.parseIsoDate(value) !== null;
58
+ /** One end of a span, or null when it is neither shape Calendar accepts. */
59
+ var toEventTimeInput = (value) => {
60
+ if (isCalendarDateOnly(value)) return { date: value };
61
+ return isIsoDateTimeWithOffset(value) ? { dateTime: value } : null;
62
+ };
63
+ /** The rule every surface states for a value that is neither shape. Shared so
64
+ * the tool, the remote host and their tests cannot drift apart. */
65
+ var EVENT_TIME_HINT = "must be an ISO 8601 date-time with a timezone offset (e.g. 2026-07-17T09:00:00+09:00), or a date for an all-day event (e.g. 2026-07-17)";
66
+ /** The same rule with the offending key named, for a surface whose error text
67
+ * carries no path of its own. */
68
+ var eventTimeHint = (key) => `${key} ${EVENT_TIME_HINT}`;
69
+ /** All-day `end` is exclusive, so a one-day event ends on the NEXT day. Spelled
70
+ * out because the natural reading of "all day on the 17th" is `end` = the
71
+ * 17th, which Calendar refuses. */
72
+ var ALL_DAY_END_HINT = "an all-day `end` is EXCLUSIVE — it is the day AFTER the last day, so a single day on 2026-07-17 needs end 2026-07-18";
73
+ var MIXED_SPAN_HINT = "start and end must both be date-times with an offset, or both be dates for an all-day event — not one of each";
74
+ /** A lone all-day end cannot be patched: the stored event's kind is unknown
75
+ * here, and giving one end of a timed event a `date` makes an event Calendar
76
+ * rejects — with a message that names neither end. Both together are
77
+ * unambiguous, so that is what the caller is asked for. */
78
+ var LONE_ALL_DAY_HINT = `pass BOTH start and end to move an all-day event — ${ALL_DAY_END_HINT}`;
79
+ var isAllDay = (time) => "date" in time;
80
+ /** Both ends of a create, validated together.
81
+ *
82
+ * Only the all-day ordering is checked: a timed span's ordering depends on the
83
+ * two offsets, which Calendar resolves and reports on clearly, while an all-day
84
+ * `end <= start` comes back as an opaque 400. Lexicographic comparison is exact
85
+ * for `YYYY-MM-DD` — fixed-width, most significant part first. */
86
+ function resolveSpanInput(start, end) {
87
+ const startTime = toEventTimeInput(start);
88
+ if (startTime === null) return {
89
+ ok: false,
90
+ reason: eventTimeHint("start")
91
+ };
92
+ const endTime = toEventTimeInput(end);
93
+ if (endTime === null) return {
94
+ ok: false,
95
+ reason: eventTimeHint("end")
96
+ };
97
+ if (isAllDay(startTime) !== isAllDay(endTime)) return {
98
+ ok: false,
99
+ reason: MIXED_SPAN_HINT
100
+ };
101
+ if (isAllDay(startTime) && isAllDay(endTime) && endTime.date <= startTime.date) return {
102
+ ok: false,
103
+ reason: ALL_DAY_END_HINT
104
+ };
105
+ return {
106
+ ok: true,
107
+ span: {
108
+ start: startTime,
109
+ end: endTime
110
+ }
111
+ };
112
+ }
113
+ /** One end of an EDIT, where the other end is absent. */
114
+ function resolveLoneEnd(key, value) {
115
+ const time = toEventTimeInput(value);
116
+ if (time === null) return {
117
+ ok: false,
118
+ reason: eventTimeHint(key)
119
+ };
120
+ if (isAllDay(time)) return {
121
+ ok: false,
122
+ reason: LONE_ALL_DAY_HINT
123
+ };
124
+ return {
125
+ ok: true,
126
+ times: { [key]: time }
127
+ };
128
+ }
129
+ /** The span of an EDIT, where either end may be absent. */
130
+ function resolvePartialSpanInput(start, end) {
131
+ if (start !== void 0 && end !== void 0) {
132
+ const resolved = resolveSpanInput(start, end);
133
+ return resolved.ok ? {
134
+ ok: true,
135
+ times: resolved.span
136
+ } : resolved;
137
+ }
138
+ if (start !== void 0) return resolveLoneEnd("start", start);
139
+ if (end !== void 0) return resolveLoneEnd("end", end);
140
+ return {
141
+ ok: true,
142
+ times: {}
143
+ };
144
+ }
145
+ //#endregion
48
146
  //#region src/google/paths.ts
49
147
  /** Same directory as every other cross-app per-user file — see
50
148
  * `../global-config/paths.ts`, which owns the spelling. */
@@ -2891,8 +2989,10 @@ async function deleteDriveFile(accessToken, input) {
2891
2989
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
2892
2990
  }
2893
2991
  //#endregion
2992
+ exports.ALL_DAY_END_HINT = ALL_DAY_END_HINT;
2894
2993
  exports.CANCELLED_EVENT_STATUS = CANCELLED_EVENT_STATUS;
2895
2994
  exports.DEFAULT_LIST_MAX_RESULTS = DEFAULT_LIST_MAX_RESULTS;
2995
+ exports.EVENT_TIME_HINT = EVENT_TIME_HINT;
2896
2996
  exports.GOOGLE_AUTH_CANCELLED = GOOGLE_AUTH_CANCELLED;
2897
2997
  exports.GOOGLE_CALENDARLIST_SCOPE = GOOGLE_CALENDARLIST_SCOPE;
2898
2998
  exports.GOOGLE_CALENDAR_SCOPE = GOOGLE_CALENDAR_SCOPE;
@@ -2904,7 +3004,9 @@ exports.GoogleApiError = GoogleApiError;
2904
3004
  exports.HTTP_CONFLICT = HTTP_CONFLICT;
2905
3005
  exports.HTTP_FORBIDDEN = HTTP_FORBIDDEN;
2906
3006
  exports.HTTP_PRECONDITION_FAILED = HTTP_PRECONDITION_FAILED;
3007
+ exports.LONE_ALL_DAY_HINT = LONE_ALL_DAY_HINT;
2907
3008
  exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
3009
+ exports.MIXED_SPAN_HINT = MIXED_SPAN_HINT;
2908
3010
  exports.PARTIAL_CALENDAR_WINDOW = PARTIAL_CALENDAR_WINDOW;
2909
3011
  exports.PROTECTION_UNKNOWN = PROTECTION_UNKNOWN;
2910
3012
  exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
@@ -2950,6 +3052,7 @@ exports.deleteCalendarEvent = deleteCalendarEvent;
2950
3052
  exports.deleteDriveFile = deleteDriveFile;
2951
3053
  exports.deleteGoogleTokens = deleteGoogleTokens;
2952
3054
  exports.deleteTask = deleteTask;
3055
+ exports.eventTimeHint = eventTimeHint;
2953
3056
  exports.fieldText = fieldText;
2954
3057
  exports.findClientSecretPath = findClientSecretPath;
2955
3058
  exports.getCalendarColors = getCalendarColors;
@@ -2965,6 +3068,7 @@ exports.googleTokenPath = googleTokenPath;
2965
3068
  exports.groupByCalendar = groupByCalendar;
2966
3069
  exports.groupsNeedingBackfill = groupsNeedingBackfill;
2967
3070
  exports.heldBack = heldBack;
3071
+ exports.isCalendarDateOnly = isCalendarDateOnly;
2968
3072
  exports.isCalendarSyncDue = isCalendarSyncDue;
2969
3073
  exports.isClientSettableEventId = isClientSettableEventId;
2970
3074
  exports.isDeniedAccessRole = isDeniedAccessRole;
@@ -3006,6 +3110,8 @@ exports.readDriveFile = readDriveFile;
3006
3110
  exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
3007
3111
  exports.reportedAccessRole = reportedAccessRole;
3008
3112
  exports.resolveEventSpan = resolveEventSpan;
3113
+ exports.resolvePartialSpanInput = resolvePartialSpanInput;
3114
+ exports.resolveSpanInput = resolveSpanInput;
3009
3115
  exports.resumableToken = resumableToken;
3010
3116
  exports.saveCalendarShadow = saveCalendarShadow;
3011
3117
  exports.saveCalendarSyncToken = saveCalendarSyncToken;
@@ -3024,6 +3130,7 @@ exports.toCollectionDateTime = toCollectionDateTime;
3024
3130
  exports.toCollectionRecord = toCollectionRecord;
3025
3131
  exports.toDriveFileSummary = toDriveFileSummary;
3026
3132
  exports.toEventSummary = toEventSummary;
3133
+ exports.toEventTimeInput = toEventTimeInput;
3027
3134
  exports.toGoogleEventTime = toGoogleEventTime;
3028
3135
  exports.toShadowEvent = toShadowEvent;
3029
3136
  exports.toTaskListSummary = toTaskListSummary;