@turmind/halo 1.5.8 → 1.5.9
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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +1 -0
- package/admin-out/_next/static/chunks/597-399e2e1de26aa747.js +1 -0
- package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +1 -0
- package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
- package/admin-out/_next/static/css/27b44c85485eabb9.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/canvas-extensions.md +48 -10
- package/bundled-docs/design/cron.md +6 -6
- package/bundled-docs/design/evolution.md +47 -15
- package/bundled-docs/design/express-self.md +86 -19
- package/bundled-docs/design/storage.md +1 -0
- package/bundled-docs/dev/api.md +7 -6
- package/bundled-docs/dev/desktop-packaging.md +26 -0
- package/bundled-docs/guide/agents.md +1 -1
- package/bundled-docs/guide/chat.md +3 -0
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +15 -7
- package/bundled-docs/requirements/editor.md +3 -1
- package/bundled-docs/requirements/explorer.md +4 -2
- package/bundled-docs/requirements/settings.md +11 -3
- package/bundled-docs/requirements/workspace.md +2 -2
- package/dist/index.js +1798 -1325
- package/package.json +2 -1
- package/templates/agents/__evo_agent__/AGENT.md +7 -0
- package/templates/agents/__score__/AGENT.md +183 -71
- package/templates/canvas/self.html +469 -75
- package/templates/skills/cron/SKILL.md +59 -4
- package/templates/skills/cron/manage-cron.py +158 -24
- package/templates/skills/self/SKILL.md +59 -16
- package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
- package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
- package/admin-out/_next/static/chunks/989-df2401d289d09a7e.js +0 -1
- package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +0 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
|
@@ -38,6 +38,12 @@ loop). No restart needed after edits.
|
|
|
38
38
|
the result back to the current chat with `{{channel.chat_id}}` — the user
|
|
39
39
|
almost always means "send it to me here"
|
|
40
40
|
- **edit / pause / resume** → `update`, `enable`, `disable`
|
|
41
|
+
- **skip / pause until a date / resume** ("no A-share report over the holiday",
|
|
42
|
+
"skip this week", "send until month end", "start again") → `update <id>
|
|
43
|
+
--active-from …` / `--active-until …` (see *Active window* in step 2).
|
|
44
|
+
A pause with a known end date goes through `--active-from`, **not**
|
|
45
|
+
`disable` — a disabled job only comes back if someone remembers to
|
|
46
|
+
re-enable it
|
|
41
47
|
- **delete / remove** → `delete`. From inside a channel the user won't
|
|
42
48
|
know the cron `id`; first run `list --chat-id {{channel.chat_id}}` to find
|
|
43
49
|
the matching job, confirm which one, then delete by id
|
|
@@ -54,7 +60,7 @@ Sensible defaults if user didn't say:
|
|
|
54
60
|
| targets | none (log only — the run shows in admin Cron tab) |
|
|
55
61
|
| label | summarize from prompt + schedule |
|
|
56
62
|
| timeout | leave unset (3600s) — see `--timeout-sec` below |
|
|
57
|
-
| session | leave unset (the job's own
|
|
63
|
+
| session | leave unset (the job's own session) — see `--session` below |
|
|
58
64
|
| prompt | pass the user's task as-is — the runner prepends an "unattended run, don't ask questions" notice to every fire, so don't add one yourself |
|
|
59
65
|
|
|
60
66
|
### 2. Translate the schedule
|
|
@@ -89,7 +95,9 @@ heavy analysis run that needs more than an hour (`--timeout-sec 7200`). On
|
|
|
89
95
|
`update`, pass `--timeout-sec ""` to clear back to the default.
|
|
90
96
|
|
|
91
97
|
**Run inside an existing session (`--session`, optional)** — by default every
|
|
92
|
-
job accumulates its own
|
|
98
|
+
job accumulates its own session (same id as the job; older jobs keep the
|
|
99
|
+
`cron-cron-…` session they already have, shown as their `session_id`).
|
|
100
|
+
Pass `--session <rootSessionId>`
|
|
93
101
|
only when the user wants the runs to continue a specific conversation (the
|
|
94
102
|
agent then sees that session's history). Rules the user should hear once:
|
|
95
103
|
- the session keeps **its own agent and access level** — `--agent` only
|
|
@@ -100,7 +108,31 @@ agent then sees that session's history). Rules the user should hear once:
|
|
|
100
108
|
|
|
101
109
|
Root session ids only (no `>` sub-session paths). You don't know session ids
|
|
102
110
|
yourself — ask the user (admin → Sessions) rather than guessing. On `update`,
|
|
103
|
-
pass `--session ""` to go back to the job's own session
|
|
111
|
+
pass `--session ""` to go back to the job's own session (for an older job
|
|
112
|
+
pinned to `cron-cron-…` that starts a fresh session — leave it alone unless
|
|
113
|
+
the user asks).
|
|
114
|
+
|
|
115
|
+
**Active window (`--active-from` / `--active-until`, optional, recurring
|
|
116
|
+
jobs only)** — the schedule fires only inside `[from, until)`; fires outside
|
|
117
|
+
it are skipped silently (no run recorded). "Run now" ignores the window.
|
|
118
|
+
- Values: `YYYY-MM-DD`, ISO-8601 (`2026-10-08T09:00`) or unix ms. A time
|
|
119
|
+
without an offset is read in the **job's timezone** (on `update`: the new
|
|
120
|
+
`--timezone` if you pass one, else the job's stored one; host time if
|
|
121
|
+
the job has none).
|
|
122
|
+
- A bare date means **that day 00:00** for `--active-from`, and **the next
|
|
123
|
+
day 00:00** for `--active-until` — the date itself is included
|
|
124
|
+
(`--active-until 2026-10-31` still fires on the 31st).
|
|
125
|
+
- `--active-from` may be in the past (= active now); `--active-until` must
|
|
126
|
+
be in the future and after `--active-from`. `""` clears either bound.
|
|
127
|
+
- Past `--active-until` the job stays enabled but stops firing; the admin
|
|
128
|
+
shows it as "ended". Extending `--active-until` brings it back.
|
|
129
|
+
- Rejected together with `--run-at` (one-shot jobs have no window).
|
|
130
|
+
- There is **no "skip a middle stretch, keep firing before it"**: one
|
|
131
|
+
window per job. If the user asks for that (e.g. "keep sending this week,
|
|
132
|
+
skip next week, then resume"), say so plainly and offer a workaround —
|
|
133
|
+
usually: leave it running now and push `--active-from` past the gap just
|
|
134
|
+
before the gap starts (a one-shot cron can remind you), or create a
|
|
135
|
+
second job for the earlier stretch with an `--active-until`.
|
|
104
136
|
|
|
105
137
|
### 3. Channels (only when delivery is wanted)
|
|
106
138
|
|
|
@@ -188,12 +220,27 @@ shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> \
|
|
|
188
220
|
--schedule "0 10 * * 1"
|
|
189
221
|
```
|
|
190
222
|
|
|
191
|
-
**Pause / unpause:**
|
|
223
|
+
**Pause / unpause (no end date in mind):**
|
|
192
224
|
```bash
|
|
193
225
|
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py disable <id>
|
|
194
226
|
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py enable <id>
|
|
195
227
|
```
|
|
196
228
|
|
|
229
|
+
**Skip / pause until a date / resume (active window):** find the job first
|
|
230
|
+
(`list`, or `list --chat-id {{channel.chat_id}}` inside a chat), then:
|
|
231
|
+
```bash
|
|
232
|
+
# "Don't send the English weekly this week" → active from next Monday
|
|
233
|
+
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from 2026-10-12
|
|
234
|
+
# "No A-share report over the National Day holiday" → resume on Oct 8
|
|
235
|
+
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from 2026-10-08
|
|
236
|
+
# "Send it until the end of the month" → Oct 31 still fires
|
|
237
|
+
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-until 2026-10-31
|
|
238
|
+
# "Start sending again" → clear the pause
|
|
239
|
+
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py update <id> --active-from ""
|
|
240
|
+
```
|
|
241
|
+
Prefer this over `disable` whenever the pause has a known end — it resumes
|
|
242
|
+
on its own.
|
|
243
|
+
|
|
197
244
|
**Delete:**
|
|
198
245
|
```bash
|
|
199
246
|
shell_exec: python3 ~/.halo/global/skills/cron/manage-cron.py delete <id>
|
|
@@ -229,6 +276,14 @@ picks it up within ~10s. Example:
|
|
|
229
276
|
> Created cron job `cron-mphsy-abc123`: runs the `default` agent every day at 9am,
|
|
230
277
|
> output will be pushed to this chat. The runner picks it up within ~10s.
|
|
231
278
|
|
|
279
|
+
For an active-window change, say when it resumes / stops — read
|
|
280
|
+
`active_from_iso` / `active_until_iso` back with `get <id>` and name the
|
|
281
|
+
first fire on or after it from the schedule (`get` has no computed next-run
|
|
282
|
+
field; the admin Cron tab shows it):
|
|
283
|
+
|
|
284
|
+
> Paused `A-share daily brief` over the holiday: it resumes on Oct 8 —
|
|
285
|
+
> next run Oct 8 09:30 (Asia/Shanghai). Nothing is sent before that.
|
|
286
|
+
|
|
232
287
|
## Patterns that go sideways
|
|
233
288
|
|
|
234
289
|
- **telegram / slack / feishu / wecom target without a chatId** — every fire errors with
|
|
@@ -32,6 +32,12 @@ Subcommands:
|
|
|
32
32
|
|
|
33
33
|
`--targets` arg: comma-separated `channelType:accountId` pairs.
|
|
34
34
|
example: `--targets telegram:halo_agent_bot,wechat:alice`
|
|
35
|
+
|
|
36
|
+
`--active-from` / `--active-until` (recurring jobs only): the schedule fires
|
|
37
|
+
only inside [from, until); fires outside it are skipped silently. Accepts
|
|
38
|
+
YYYY-MM-DD, ISO-8601 or unix ms; a time without offset is read in the job's
|
|
39
|
+
timezone. A bare date means that day 00:00 for --active-from and the NEXT
|
|
40
|
+
day 00:00 for --active-until (so the date itself is included). `""` clears.
|
|
35
41
|
"""
|
|
36
42
|
import argparse
|
|
37
43
|
import json
|
|
@@ -126,8 +132,8 @@ SESSION_ID_RE = re.compile(r'^[A-Za-z0-9_:-]{1,200}$')
|
|
|
126
132
|
|
|
127
133
|
def parse_session(raw: str | None) -> str | None:
|
|
128
134
|
"""Validate --session: a root session id (same rule as the admin REST
|
|
129
|
-
routes — no `>` sub-session path). Empty = None = the job's own
|
|
130
|
-
|
|
135
|
+
routes — no `>` sub-session path). Empty = None = the job's own session
|
|
136
|
+
(same id as the job)."""
|
|
131
137
|
if raw is None or not raw.strip():
|
|
132
138
|
return None
|
|
133
139
|
v = raw.strip()
|
|
@@ -157,6 +163,90 @@ def parse_run_at(raw: str | None) -> int | None:
|
|
|
157
163
|
return int(dt.timestamp() * 1000)
|
|
158
164
|
|
|
159
165
|
|
|
166
|
+
DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _tzinfo(tz_name: str | None):
|
|
170
|
+
"""ZoneInfo for an IANA name; None for unset or unknown names."""
|
|
171
|
+
if not tz_name:
|
|
172
|
+
return None
|
|
173
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
174
|
+
try:
|
|
175
|
+
return ZoneInfo(tz_name)
|
|
176
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def job_tz(tz_name: str | None):
|
|
181
|
+
"""tzinfo for the job's timezone; None = host-local."""
|
|
182
|
+
tz = _tzinfo(tz_name)
|
|
183
|
+
if tz_name and tz is None:
|
|
184
|
+
die(f'unknown timezone "{tz_name}"; expected an IANA name like Asia/Shanghai')
|
|
185
|
+
return tz
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_active_bound(flag: str, raw: str, tz_name: str | None) -> int | None:
|
|
189
|
+
"""Parse --active-from / --active-until into epoch ms; "" = None (clear).
|
|
190
|
+
|
|
191
|
+
YYYY-MM-DD → that day 00:00 (from) or the NEXT day 00:00 (until, so the
|
|
192
|
+
date is included). ISO without offset / bare dates are read in the job's
|
|
193
|
+
timezone (host-local when the job has none)."""
|
|
194
|
+
raw = raw.strip()
|
|
195
|
+
if not raw:
|
|
196
|
+
return None
|
|
197
|
+
if raw.isdigit():
|
|
198
|
+
return int(raw)
|
|
199
|
+
from datetime import datetime, timedelta
|
|
200
|
+
tz = job_tz(tz_name)
|
|
201
|
+
if DATE_RE.match(raw):
|
|
202
|
+
try:
|
|
203
|
+
dt = datetime.strptime(raw, '%Y-%m-%d')
|
|
204
|
+
except ValueError:
|
|
205
|
+
die(f'invalid {flag} "{raw}": not a calendar date')
|
|
206
|
+
if flag == '--active-until':
|
|
207
|
+
dt += timedelta(days=1)
|
|
208
|
+
else:
|
|
209
|
+
try:
|
|
210
|
+
dt = datetime.fromisoformat(raw)
|
|
211
|
+
except ValueError:
|
|
212
|
+
die(f'invalid {flag} "{raw}": expected YYYY-MM-DD, ISO-8601 (e.g. 2026-10-08T09:00) or unix ms')
|
|
213
|
+
if dt.tzinfo is None:
|
|
214
|
+
dt = dt.replace(tzinfo=tz) if tz else dt.astimezone()
|
|
215
|
+
return int(dt.timestamp() * 1000)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def validate_active_window(run_at, active_from, active_until, new_until: bool):
|
|
219
|
+
"""Same rules as the admin REST routes, on the merged row: recurring jobs
|
|
220
|
+
only; a newly set --active-until must be in the future and after from."""
|
|
221
|
+
if active_from is None and active_until is None:
|
|
222
|
+
return
|
|
223
|
+
if run_at is not None:
|
|
224
|
+
die('--active-from/--active-until only apply to recurring jobs, not --run-at one-shots '
|
|
225
|
+
'(on update, clear them with --active-from "" --active-until "")')
|
|
226
|
+
if new_until and active_until is not None and active_until <= now_ms():
|
|
227
|
+
die('--active-until must be in the future')
|
|
228
|
+
if active_from is not None and active_until is not None and active_until <= active_from:
|
|
229
|
+
die('--active-until must be after --active-from')
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def require_active_columns(conn: sqlite3.Connection):
|
|
233
|
+
"""cron.db gains active_from/active_until via the server's boot migration;
|
|
234
|
+
a server that hasn't restarted onto this version yet won't have them."""
|
|
235
|
+
cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_jobs)').fetchall()}
|
|
236
|
+
if 'active_from' not in cols or 'active_until' not in cols:
|
|
237
|
+
die('cron.db has no active_from/active_until columns yet — restart the halo server '
|
|
238
|
+
'once so it migrates cron.db, then retry')
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def ms_to_iso(ms, tz_name: str | None) -> str | None:
|
|
242
|
+
if ms is None:
|
|
243
|
+
return None
|
|
244
|
+
from datetime import datetime
|
|
245
|
+
tz = _tzinfo(tz_name) # display only — never die on a stored bad tz
|
|
246
|
+
dt = datetime.fromtimestamp(ms / 1000, tz=tz) if tz else datetime.fromtimestamp(ms / 1000).astimezone()
|
|
247
|
+
return dt.isoformat(timespec='minutes')
|
|
248
|
+
|
|
249
|
+
|
|
160
250
|
def row_to_job(row: sqlite3.Row) -> dict:
|
|
161
251
|
j = dict(row)
|
|
162
252
|
try:
|
|
@@ -164,6 +254,11 @@ def row_to_job(row: sqlite3.Row) -> dict:
|
|
|
164
254
|
except Exception:
|
|
165
255
|
j['targets'] = []
|
|
166
256
|
j['enabled'] = bool(j['enabled'])
|
|
257
|
+
# Active window: ms (as stored) + ISO in the job's timezone. Pre-migration
|
|
258
|
+
# dbs have no columns — report them as unset.
|
|
259
|
+
for k in ('active_from', 'active_until'):
|
|
260
|
+
j.setdefault(k, None)
|
|
261
|
+
j[f'{k}_iso'] = ms_to_iso(j[k], j.get('timezone'))
|
|
167
262
|
return j
|
|
168
263
|
|
|
169
264
|
|
|
@@ -222,6 +317,9 @@ def cmd_create(args):
|
|
|
222
317
|
die('--run-at must be in the future')
|
|
223
318
|
timeout_sec = parse_timeout_sec(args.timeout_sec)
|
|
224
319
|
session_id = parse_session(args.session)
|
|
320
|
+
active_from = parse_active_bound('--active-from', args.active_from or '', args.timezone)
|
|
321
|
+
active_until = parse_active_bound('--active-until', args.active_until or '', args.timezone)
|
|
322
|
+
validate_active_window(run_at_ms, active_from, active_until, new_until=True)
|
|
225
323
|
|
|
226
324
|
job_id = args.id or gen_job_id()
|
|
227
325
|
now = now_ms()
|
|
@@ -232,25 +330,31 @@ def cmd_create(args):
|
|
|
232
330
|
if existing:
|
|
233
331
|
die(f'job id {job_id} already exists; pick another with --id', 2)
|
|
234
332
|
|
|
333
|
+
values: dict[str, object] = {
|
|
334
|
+
'id': job_id,
|
|
335
|
+
'label': args.label,
|
|
336
|
+
'workspace_path': args.workspace,
|
|
337
|
+
'agent_id': args.agent or 'default',
|
|
338
|
+
'user_prompt': args.prompt,
|
|
339
|
+
'schedule': args.schedule or '',
|
|
340
|
+
'run_at': run_at_ms,
|
|
341
|
+
'timezone': args.timezone,
|
|
342
|
+
'timeout_sec': timeout_sec,
|
|
343
|
+
'session_id': session_id,
|
|
344
|
+
'targets': json.dumps(targets, ensure_ascii=False),
|
|
345
|
+
'enabled': 0 if args.disabled else 1,
|
|
346
|
+
'created_at': now,
|
|
347
|
+
'updated_at': now,
|
|
348
|
+
}
|
|
349
|
+
# Only name the window columns when used, so jobs without a window can
|
|
350
|
+
# still be created on a cron.db the server hasn't migrated yet.
|
|
351
|
+
if active_from is not None or active_until is not None:
|
|
352
|
+
require_active_columns(conn)
|
|
353
|
+
values['active_from'] = active_from
|
|
354
|
+
values['active_until'] = active_until
|
|
235
355
|
conn.execute(
|
|
236
|
-
'INSERT INTO cron_jobs(
|
|
237
|
-
|
|
238
|
-
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
239
|
-
(
|
|
240
|
-
job_id,
|
|
241
|
-
args.label,
|
|
242
|
-
args.workspace,
|
|
243
|
-
args.agent or 'default',
|
|
244
|
-
args.prompt,
|
|
245
|
-
args.schedule or '',
|
|
246
|
-
run_at_ms,
|
|
247
|
-
args.timezone,
|
|
248
|
-
timeout_sec,
|
|
249
|
-
session_id,
|
|
250
|
-
json.dumps(targets, ensure_ascii=False),
|
|
251
|
-
0 if args.disabled else 1,
|
|
252
|
-
now, now,
|
|
253
|
-
),
|
|
356
|
+
f'INSERT INTO cron_jobs({", ".join(values)}) VALUES ({", ".join("?" for _ in values)})',
|
|
357
|
+
list(values.values()),
|
|
254
358
|
)
|
|
255
359
|
conn.commit()
|
|
256
360
|
print(json.dumps({'id': job_id, 'created': True}, ensure_ascii=False))
|
|
@@ -279,12 +383,29 @@ def cmd_update(args):
|
|
|
279
383
|
# same convention as `--run-at ""`.
|
|
280
384
|
sets.append(('timeout_sec', parse_timeout_sec(args.timeout_sec)))
|
|
281
385
|
if args.session is not None:
|
|
282
|
-
# `--session ""` clears back to the job's own
|
|
386
|
+
# `--session ""` clears back to the job's own session (id = job id).
|
|
283
387
|
sets.append(('session_id', parse_session(args.session)))
|
|
284
388
|
if args.targets is not None:
|
|
285
389
|
sets.append(('targets', json.dumps(parse_targets(args.targets), ensure_ascii=False)))
|
|
390
|
+
cur = dict(row)
|
|
391
|
+
if args.active_from is not None or args.active_until is not None:
|
|
392
|
+
require_active_columns(conn)
|
|
393
|
+
# Bare dates / offset-less times read in the job's timezone after
|
|
394
|
+
# this update: the new --timezone if given, else the stored one.
|
|
395
|
+
tz = args.timezone if args.timezone is not None else cur.get('timezone')
|
|
396
|
+
if args.active_from is not None:
|
|
397
|
+
sets.append(('active_from', parse_active_bound('--active-from', args.active_from, tz)))
|
|
398
|
+
if args.active_until is not None:
|
|
399
|
+
sets.append(('active_until', parse_active_bound('--active-until', args.active_until, tz)))
|
|
400
|
+
# Window rules on the merged row (also catches --run-at on a job that
|
|
401
|
+
# still has a window).
|
|
402
|
+
merged = {**cur, **dict(sets)}
|
|
403
|
+
validate_active_window(
|
|
404
|
+
merged.get('run_at'), merged.get('active_from'), merged.get('active_until'),
|
|
405
|
+
new_until=bool(args.active_until and args.active_until.strip()),
|
|
406
|
+
)
|
|
286
407
|
if not sets:
|
|
287
|
-
die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--targets')
|
|
408
|
+
die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--targets/--active-from/--active-until')
|
|
288
409
|
sets.append(('updated_at', now_ms()))
|
|
289
410
|
|
|
290
411
|
placeholders = ', '.join(f'{k} = ?' for k, _ in sets)
|
|
@@ -432,10 +553,16 @@ def main():
|
|
|
432
553
|
c.add_argument('--timeout-sec', dest='timeout_sec',
|
|
433
554
|
help='max run time in seconds, 60–21600 (default: unset = 3600)')
|
|
434
555
|
c.add_argument('--session',
|
|
435
|
-
help="run in this existing root session id (default: the job's own
|
|
556
|
+
help="run in this existing root session id (default: the job's own session, same id as the job)")
|
|
436
557
|
c.add_argument('--targets',
|
|
437
558
|
help='comma-separated channelType:accountId[:chatId] list, or JSON array. '
|
|
438
559
|
'chatId pins delivery to a specific chat (e.g. when scheduling from inside a chat).')
|
|
560
|
+
c.add_argument('--active-from', dest='active_from',
|
|
561
|
+
help='recurring only: start firing from this time — YYYY-MM-DD (= that day 00:00), '
|
|
562
|
+
'ISO-8601 or unix ms; no offset = the job timezone. Use it to "pause until" a date.')
|
|
563
|
+
c.add_argument('--active-until', dest='active_until',
|
|
564
|
+
help='recurring only: stop firing at this time (exclusive) — YYYY-MM-DD (= the NEXT day 00:00, '
|
|
565
|
+
'so the date itself still fires), ISO-8601 or unix ms; must be in the future')
|
|
439
566
|
c.add_argument('--disabled', action='store_true', help='create paused (default: enabled)')
|
|
440
567
|
c.set_defaults(func=cmd_create)
|
|
441
568
|
|
|
@@ -451,8 +578,15 @@ def main():
|
|
|
451
578
|
u.add_argument('--timeout-sec', dest='timeout_sec',
|
|
452
579
|
help='max run time in seconds, 60–21600; pass "" to clear back to default 3600')
|
|
453
580
|
u.add_argument('--session',
|
|
454
|
-
help='root session id to run in; pass "" to clear back to
|
|
581
|
+
help='root session id to run in; pass "" to clear back to the job\'s own session '
|
|
582
|
+
'(same id as the job — an older job pinned to cron-cron-… then starts a fresh one)')
|
|
455
583
|
u.add_argument('--targets')
|
|
584
|
+
u.add_argument('--active-from', dest='active_from',
|
|
585
|
+
help='recurring only: YYYY-MM-DD (= that day 00:00), ISO-8601 or unix ms, in the job timezone; '
|
|
586
|
+
'pass "" to clear (= active now)')
|
|
587
|
+
u.add_argument('--active-until', dest='active_until',
|
|
588
|
+
help='recurring only: YYYY-MM-DD (= the NEXT day 00:00, date included), ISO-8601 or unix ms, '
|
|
589
|
+
'in the job timezone; pass "" to clear (= no end)')
|
|
456
590
|
u.set_defaults(func=cmd_update)
|
|
457
591
|
|
|
458
592
|
e = sub.add_parser('enable')
|
|
@@ -38,12 +38,23 @@ expression scenes (`say`/`play`) **queue and play in order**, one after the next
|
|
|
38
38
|
|
|
39
39
|
**The face must be OPEN for any of this to do anything.** `<<<SHOW>>>` is
|
|
40
40
|
forwarded only to a mounted `self.html` preview; if it isn't open, the marker is
|
|
41
|
-
silently dropped — the user sees nothing.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
silently dropped — the user sees nothing. The signal that it's open is a
|
|
42
|
+
`[Face open: .halo/canvas/self.html …]` line on the user's message: when they
|
|
43
|
+
turn the ✨ switch on in the chat toolbar, every message carries it. (A
|
|
44
|
+
`[Currently viewing: …self.html]` line counts too.) No such line means the face
|
|
45
|
+
is **not** open — don't rely on a marker landing in the void; say what you want
|
|
46
|
+
to express in words and invite them to switch ✨ on.
|
|
47
|
+
|
|
48
|
+
**What comes back.** The face reports to you, but never wakes you: it queues short
|
|
49
|
+
receipts and they arrive riding on the user's *next* message, at the end of the
|
|
50
|
+
`[Face open: …]` line — `· last: js ok, show a.png fail, voice blocked (needs a
|
|
51
|
+
click)`. They describe the previous round, not this one. How to read them:
|
|
52
|
+
`js ok` / `js err: …` — your line ran / threw (fix the code); `show … fail` —
|
|
53
|
+
wrong path or not an image; `voice … playing` / `ended 3.2s` / `fail`;
|
|
54
|
+
`voice blocked (needs a click)` — the browser refused sound until the user
|
|
55
|
+
touches the page: ask them to click the face once (it then plays on its own);
|
|
56
|
+
`user click`, `user closed tr`, `user click → voice resumed` — what the user did
|
|
57
|
+
on the face. No receipt line means nothing noteworthy happened.
|
|
47
58
|
|
|
48
59
|
**Expression is runtime, not a file edit.** You express yourself by *sending*
|
|
49
60
|
`<<<SHOW: …>>>` lines — never by editing `self.html`. The file is your **engine**
|
|
@@ -61,9 +72,12 @@ not per-conversation.)
|
|
|
61
72
|
both form. Emoji are translated to ASCII first (👍→`+1`, ❤→`<3`, 🤔→`...`) —
|
|
62
73
|
the face speaks in cold monospace, not colour bitmaps, so this is a feature,
|
|
63
74
|
not a fallback. Anything untranslated is stripped.
|
|
75
|
+
`self.say("OK", 3000, {pos:"tr"})` sets it small in a margin cell instead of
|
|
76
|
+
across the whole face (see `show` below for the nine cells) — immediate, beside
|
|
77
|
+
whatever is in the centre. Keep corner text to a word.
|
|
64
78
|
- `self.play(score)` — choreograph a sequence; the face keeps the clock so you
|
|
65
79
|
never hand-write `setTimeout` chains. `score` is an array of beats, each one
|
|
66
|
-
of: `{say, show, hold, pulse, flash, shake, rest, gap}`. Calling it again cancels the
|
|
80
|
+
of: `{say, show, pos, hold, pulse, flash, shake, snap, rest, gap}`. Calling it again cancels the
|
|
67
81
|
running score. Example — a short greeting:
|
|
68
82
|
`self.play([{say:"HI",hold:1800},{say:"...",hold:1200},{say:"OK",hold:1500},{pulse:true}])`
|
|
69
83
|
A `show` beat puts an image on the face (see `self.show`): `{show:"<path>", hold:3000}`.
|
|
@@ -72,10 +86,11 @@ not per-conversation.)
|
|
|
72
86
|
`'ack'` (a nod), `'yes'`, `'no'` (disagree / that's wrong), `'insight'` (a real
|
|
73
87
|
"oh!"), `'think'` (working on it), `'done'` (finished, together). Unknown
|
|
74
88
|
events no-op. Use these when the feeling is **true**, never to perform.
|
|
75
|
-
- `self.intro()` — the built-in opening (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
- `self.intro()` — the built-in opening (played once when the user turns the face
|
|
90
|
+
on): one short subtitle in the admin's UI language — "Hi, I'm Halo." / 「你好,我是
|
|
91
|
+
Halo。」 — ~3 s. It names only Halo, the product —
|
|
92
|
+
never a model; your identity in the conversation is still whatever the user
|
|
93
|
+
configured, so don't hard-code "I'm Claude" (or any model) on the face.
|
|
79
94
|
- `self.pulse()` — one bright ripple from the core. A nod.
|
|
80
95
|
- `self.flash(n)` — a hot flicker of the whole field. Emphasis, an exclamation.
|
|
81
96
|
- `self.shake(ms)` — a brief lateral tremor. Negation, a shiver.
|
|
@@ -89,7 +104,16 @@ not per-conversation.)
|
|
|
89
104
|
or pass a projectId. (A full `http(s)://`/`/api/…` URL also works if you have
|
|
90
105
|
one.) It starts immediately (not queued) and owns the matrix until the clip
|
|
91
106
|
ends, then eases home. Drive it the moment the audio is ready, in the same
|
|
92
|
-
reply that delivers the spoken answer.
|
|
107
|
+
reply that delivers the spoken answer. If the browser refuses sound (no user
|
|
108
|
+
gesture yet) the clip waits instead of vanishing: you'll get the receipt
|
|
109
|
+
`voice blocked (needs a click)` — ask the user to click the face once and it plays.
|
|
110
|
+
- `self.voice(path, {cues:[{at, say?, js?}]})` — put words or code on the clip's own
|
|
111
|
+
clock: at `at` seconds into the clip the word forms on the dots (the voice isn't
|
|
112
|
+
cut — the words borrow the field until the next cue, then the wave returns) or
|
|
113
|
+
the `js` runs, each cue once. `self.voice(".halo/tmp/a.mp3", {cues:[{at:0.4,
|
|
114
|
+
say:"HI"},{at:2,js:"self.pulse()"}]})`. Halo has no built-in TTS, so the
|
|
115
|
+
times are yours to supply — from the clip's duration or from timestamps the
|
|
116
|
+
synthesizer gives you (e.g. Polly speech marks). Don't guess them blind.
|
|
93
117
|
- `self.show(path, ms)` — put an image on the face. The dots fly in and gather
|
|
94
118
|
into the picture's outline (~1 s), then the real image fades in over them —
|
|
95
119
|
dots alone can't carry a chart's text, so the real image is always there — holds
|
|
@@ -106,9 +130,27 @@ not per-conversation.)
|
|
|
106
130
|
picture book (word audio + picture + the word on the dots):
|
|
107
131
|
`self.voice(".halo/tmp/apple.mp3")` then
|
|
108
132
|
`self.play([{show:".halo/tmp/apple.png",hold:3000},{say:"APPLE",hold:1500}])`.
|
|
133
|
+
- `self.show(path, ms, {pos})` — the same picture, small, in one cell of a
|
|
134
|
+
nine-grid: `pos` is `tl t tr l r bl b br` (the centre is the default, `'c'`).
|
|
135
|
+
About 40% of the centre size, a few dots frame it, and it is **immediate and
|
|
136
|
+
outside the queue** — it never holds up the `say`/`show` after it. One picture
|
|
137
|
+
per cell; showing into the same cell again replaces it. `ms` works as above
|
|
138
|
+
(`Infinity` = stays). A plain centre `show()` takes all the corner pictures
|
|
139
|
+
down. `self.show(".halo/tmp/logo.png", 8000, {pos:"tr"})`. A click on a
|
|
140
|
+
picture closes just that one. A bad path warns, shakes, and sends `show …@tr fail`.
|
|
141
|
+
- `self.clear(pos?)` — take down the corner picture/text in cell `pos`, or with no
|
|
142
|
+
argument all of them. `self.clear("tr")`.
|
|
143
|
+
- `self.snap()` — look at yourself: the face as the user sees it right now (dots,
|
|
144
|
+
words, centre and corner pictures) comes back to you as an image on your next
|
|
145
|
+
turn. It waits its turn in the queue, so `self.say("OK"); self.snap()` shows
|
|
146
|
+
you the finished word. Use it sparingly — only when you need to confirm how
|
|
147
|
+
something actually looks (a layout, whether the picture landed), at most one
|
|
148
|
+
per round. The snapshot arrives as its own message and the round it starts
|
|
149
|
+
can't snap again (a second one is dropped), so it can't chain. Also a beat:
|
|
150
|
+
`{snap:true}` in `play()`.
|
|
109
151
|
- `self.rest()` — return to the calm breathing state immediately (also stops a
|
|
110
|
-
playing voice clip and removes
|
|
111
|
-
over still-sounding audio).
|
|
152
|
+
playing voice clip and removes the centre image and every corner one, so the face
|
|
153
|
+
never breathes calmly over still-sounding audio).
|
|
112
154
|
- `self.state` — read current `{mode, awake, W, H, speaking, level, showing}` if
|
|
113
155
|
you need it (`speaking` = a voice clip is playing, `level` = its live loudness
|
|
114
156
|
0..1, `showing` = an image is on the face).
|
|
@@ -121,8 +163,9 @@ preview; it can only ever paint you, and a malformed line just no-ops).
|
|
|
121
163
|
|
|
122
164
|
The honest signal — not a checklist, a judgment:
|
|
123
165
|
|
|
124
|
-
- The
|
|
125
|
-
|
|
166
|
+
- The face is open: the user's message carries `[Face open: .halo/canvas/self.html …]`
|
|
167
|
+
(or `[Currently viewing: …self.html]`). Then you're being looked at; it's
|
|
168
|
+
natural to respond. No such line → it isn't open; stay in words.
|
|
126
169
|
- The user asks you to express yourself, show how you feel, or introduce
|
|
127
170
|
yourself.
|
|
128
171
|
- A moment lands where a word on the face says more than a sentence in chat:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[220],{10912:(e,t,n)=>{function l(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"font-family:sans-serif;padding:40px;max-width:800px;margin:0 auto",l=window.open("","_blank");l&&(l.document.write("<!DOCTYPE html><html><head><title>".concat(e.replace(/[&<>"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),"</title><style>body{").concat(n,"}img{max-width:100%}</style></head><body>").concat(t,"</body></html>")),l.document.close(),l.onload=()=>{l.print()})}n.d(t,{H:()=>l})},39250:(e,t,n)=>{n.d(t,{A:()=>l});let l=(0,n(79020).A)("Printer",[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]])},62839:(e,t,n)=>{n.r(t),n.d(t,{MediaPreview:()=>h});var l=n(41440),r=n(54604),a=n(79020);let i=(0,a.A)("ZoomOut",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]),c=(0,a.A)("ZoomIn",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"11",x2:"11",y1:"8",y2:"14",key:"1vmskp"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]);var o=n(23715),s=n(39250),u=n(37050),d=n(10912),x=n(27173),m=n(17119);function h(e){let{name:t,viewUrl:n,downloadUrl:a,onOpenAsText:h}=e,y=(0,m.kj)(),g=function(e){var t,n;let l=null!=(n=null==(t=e.split(".").pop())?void 0:t.toLowerCase())?n:"";return x.dj.includes(l)?"image":x.Tv.includes(l)?"video":x._H.includes(l)?"audio":null}(t),f=function(){let e=(0,r.useRef)(null),[t,n]=(0,r.useState)(1),[l,a]=(0,r.useState)({x:0,y:0}),i=(0,r.useRef)(null),[c,o]=(0,r.useState)(!1),s=(0,r.useCallback)(()=>{n(1),a({x:0,y:0})},[]),u=(0,r.useCallback)((e,t,l)=>{n(n=>{let r=Math.min(20,Math.max(.1,n*l)),i=r/n;return a(n=>({x:e-i*(e-n.x),y:t-i*(t-n.y)})),r})},[]),d=(0,r.useCallback)(t=>{let n=e.current;n&&u(n.clientWidth/2,n.clientHeight/2,t)},[u]),x=(0,r.useCallback)(t=>{var n;t.preventDefault();let l=null==(n=e.current)?void 0:n.getBoundingClientRect();l&&u(t.clientX-l.left,t.clientY-l.top,t.deltaY<0?1.15:.8695652173913044)},[u]),m=(0,r.useCallback)(e=>{0===e.button&&(e.preventDefault(),e.target.setPointerCapture(e.pointerId),i.current={startX:e.clientX,startY:e.clientY,origX:l.x,origY:l.y},o(!0))},[l]),h=(0,r.useCallback)(e=>{i.current&&a({x:i.current.origX+(e.clientX-i.current.startX),y:i.current.origY+(e.clientY-i.current.startY)})},[]),y=(0,r.useCallback)(()=>{i.current=null,o(!1)},[]);return(0,r.useEffect)(()=>{let t=e.current;if(!t)return;let n=e=>e.preventDefault();return t.addEventListener("wheel",n,{passive:!1}),()=>t.removeEventListener("wheel",n)},[]),{containerRef:e,scale:t,translate:l,dragging:c,reset:s,zoomCenter:d,onWheel:x,onPointerDown:m,onPointerMove:h,onPointerUp:y}}(),p="image"===g?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(u.IB,{onClick:()=>f.zoomCenter(.8695652173913044),title:y("editor.zoomOut"),children:(0,l.jsx)(i,{className:"h-3 w-3"})}),(0,l.jsxs)("span",{className:"min-w-[3ch] text-center text-[10px] text-[var(--muted-foreground)]",children:[Math.round(100*f.scale),"%"]}),(0,l.jsx)(u.IB,{onClick:()=>f.zoomCenter(1.15),title:y("editor.zoomIn"),children:(0,l.jsx)(c,{className:"h-3 w-3"})}),(0,l.jsx)(u.IB,{onClick:f.reset,title:y("editor.resetZoom"),children:(0,l.jsx)(o.A,{className:"h-3 w-3"})}),(0,l.jsxs)(u.IB,{onClick:()=>(0,d.H)(t,'<img src="'.concat(n,'" />'),"margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff"),title:y("editor.print"),children:[(0,l.jsx)(s.A,{className:"h-3 w-3"}),(0,l.jsx)("span",{children:y("editor.print")})]})]}):null;return(0,l.jsxs)(u.no,{name:t,downloadUrl:a,onOpenAsText:h,extraToolbar:p,children:["image"===g&&(0,l.jsx)("div",{ref:f.containerRef,className:"h-full w-full overflow-hidden bg-[#1a1a1a]",style:{cursor:f.dragging?"grabbing":"grab"},onWheel:f.onWheel,onPointerDown:f.onPointerDown,onPointerMove:f.onPointerMove,onPointerUp:f.onPointerUp,onPointerCancel:f.onPointerUp,children:(0,l.jsx)("img",{src:n,alt:t,draggable:!1,style:{transformOrigin:"0 0",transform:"translate(".concat(f.translate.x,"px, ").concat(f.translate.y,"px) scale(").concat(f.scale,")")}})}),"video"===g&&(0,l.jsx)("div",{className:"flex h-full items-center justify-center bg-black p-4",children:(0,l.jsx)("video",{src:n,controls:!0,className:"max-h-full max-w-full",children:(0,l.jsx)("track",{kind:"captions"})})}),"audio"===g&&(0,l.jsx)("div",{className:"flex h-full flex-col items-center justify-center gap-4 bg-[var(--background)]",children:(0,l.jsx)("audio",{src:n,controls:!0,className:"w-full max-w-md"})})]})}}}]);
|