@turmind/halo 1.5.8 → 1.5.10

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 (40) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +1 -0
  3. package/admin-out/_next/static/chunks/388-d5950f264d91625a.js +1 -0
  4. package/admin-out/_next/static/chunks/989-1797287c46139412.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
  7. package/admin-out/_next/static/css/d53aa54212002364.css +3 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/canvas-extensions.md +60 -10
  11. package/bundled-docs/design/cron.md +6 -6
  12. package/bundled-docs/design/evolution.md +47 -15
  13. package/bundled-docs/design/express-self.md +90 -23
  14. package/bundled-docs/design/storage.md +1 -0
  15. package/bundled-docs/dev/api.md +7 -6
  16. package/bundled-docs/dev/desktop-packaging.md +26 -0
  17. package/bundled-docs/guide/agents.md +1 -1
  18. package/bundled-docs/guide/chat.md +3 -0
  19. package/bundled-docs/guide/workspace.md +2 -2
  20. package/bundled-docs/requirements/agent.md +1 -1
  21. package/bundled-docs/requirements/chat.md +29 -15
  22. package/bundled-docs/requirements/editor.md +4 -1
  23. package/bundled-docs/requirements/explorer.md +4 -2
  24. package/bundled-docs/requirements/settings.md +11 -3
  25. package/bundled-docs/requirements/workspace.md +2 -2
  26. package/dist/index.js +1798 -1325
  27. package/package.json +2 -1
  28. package/templates/agents/__evo_agent__/AGENT.md +7 -0
  29. package/templates/agents/__score__/AGENT.md +183 -71
  30. package/templates/canvas/self.html +469 -75
  31. package/templates/skills/cron/SKILL.md +59 -4
  32. package/templates/skills/cron/manage-cron.py +158 -24
  33. package/templates/skills/self/SKILL.md +59 -16
  34. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
  35. package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
  36. package/admin-out/_next/static/chunks/989-df2401d289d09a7e.js +0 -1
  37. package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +0 -1
  38. package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
  39. /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → A-Rjx2yltZDKaSO1qVv7K}/_buildManifest.js +0 -0
  40. /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → A-Rjx2yltZDKaSO1qVv7K}/_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 `cron-<jobId>`) — see `--session` below |
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 `cron-<jobId>` session. Pass `--session <rootSessionId>`
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
- `cron-<jobId>` session."""
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(id, label, workspace_path, agent_id, user_prompt, schedule, '
237
- 'run_at, timezone, timeout_sec, session_id, targets, enabled, created_at, updated_at) '
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 cron-<jobId> session.
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 cron-<jobId>)")
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 cron-<jobId>')
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. So drive the face only when you have
42
- reason to believe it's open: the user is viewing it (`[Currently viewing:
43
- …self.html]` is in your context), they just opened it, or they asked you to. If
44
- you want to express something and it may not be open, say so in words and invite
45
- them to open it (the ✨ button in the chat toolbar, or just opening
46
- `.halo/canvas/self.html`) — don't rely on a marker landing in the void.
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 (auto-plays once on load). A nameless
76
- greeting: "HELLO / A MIND / IS HERE / BEYOND WORDS". Deliberately no name — the
77
- conversational identity is user-configurable and the model may not be Claude,
78
- so don't hard-code "I'm Claude" on the face; let it speak the universal thing.
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 a shown image, so the face never breathes calmly
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 user is viewing `self.html` (you'll see `[Currently viewing: …self.html]`
125
- in context). Then you're being looked at; it's natural to respond.
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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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"})})]})}}}]);