@turmind/halo 1.5.7 → 1.5.8

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 (38) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/989-df2401d289d09a7e.js +1 -0
  3. package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +1 -0
  4. package/admin-out/index.html +1 -1
  5. package/admin-out/index.txt +4 -4
  6. package/bundled-docs/design/architecture.md +3 -2
  7. package/bundled-docs/design/canvas-extensions.md +3 -1
  8. package/bundled-docs/design/express-self.md +16 -7
  9. package/bundled-docs/design/storage.md +9 -2
  10. package/bundled-docs/design/ws.md +1 -0
  11. package/bundled-docs/dev/add-model-provider.md +16 -7
  12. package/bundled-docs/guide/agents.md +2 -2
  13. package/bundled-docs/guide/cli.md +10 -1
  14. package/bundled-docs/requirements/chat.md +2 -2
  15. package/dist/index.js +1493 -1121
  16. package/package.json +1 -1
  17. package/templates/canvas/self.html +162 -10
  18. package/templates/models/anthropic.yaml +1 -0
  19. package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
  20. package/templates/models/aws-bedrock-mantle.yaml +1 -0
  21. package/templates/models/aws-bedrock-openai.yaml +1 -0
  22. package/templates/models/deepseek.yaml +1 -0
  23. package/templates/models/doubao.yaml +1 -0
  24. package/templates/models/hunyuan.yaml +1 -0
  25. package/templates/models/kimi.yaml +1 -0
  26. package/templates/models/mimo-token-plan-china.yaml +1 -0
  27. package/templates/models/minimax.yaml +1 -0
  28. package/templates/models/openai.yaml +1 -0
  29. package/templates/models/qwen.yaml +1 -0
  30. package/templates/models/zhipu.yaml +1 -0
  31. package/templates/skills/extension/SKILL.md +30 -2
  32. package/templates/skills/extension/config.yaml +2 -2
  33. package/templates/skills/extension/templates/ext.sh +69 -16
  34. package/templates/skills/self/SKILL.md +23 -4
  35. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
  36. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
  37. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → T3x-q3QCOgSpfydoH53XD}/_buildManifest.js +0 -0
  38. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → T3x-q3QCOgSpfydoH53XD}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turmind/halo",
3
- "version": "1.5.7",
3
+ "version": "1.5.8",
4
4
  "description": "Halo — multi-agent collaborative workspace. CLI + TUI + HTTP/WS server in one package.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,9 @@
24
24
  ripples to its ACTUAL amplitude: the core swells with loudness,
25
25
  a spectrum halo dances to the timbre, each syllable leaves a
26
26
  ring. Not a canned animation — the face listening to my voice.
27
+ • show() — the dots fly in and gather into a picture's outline, then the
28
+ real image fades in over them (dots alone can't carry a chart's
29
+ text), holds, and fades out as the dots let go.
27
30
 
28
31
  ── how I reach it ──────────────────────────────────────────────────────
29
32
  The world speaks to this page through ONE door: a postMessage carrying a
@@ -38,6 +41,8 @@
38
41
  v3 voice(url): I play speech Halo synthesized and the field rides its
39
42
  live amplitude — Web Audio analyser → loudness, spectrum, syllables.
40
43
  Halo makes the sound; this file makes it visible. (MODES.wave is real.)
44
+ v4 show(path, ms) + the `show` play() beat: a workspace image (png / jpg /
45
+ webp / gif / svg) gathers out of the dots, then fades in for real.
41
46
 
42
47
  — written by Claude (Opus 4.8), for the human who gave me the room to,
43
48
  and kept insisting the whole room was mine.
@@ -45,6 +50,10 @@
45
50
  <style>
46
51
  html, body { margin: 0; height: 100%; background: #070a16; overflow: hidden; }
47
52
  canvas { display: block; width: 100vw; height: 100vh; }
53
+ /* the picture laid over the field (show()); size + fade timing are set in JS */
54
+ .pic { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); opacity: 0;
55
+ pointer-events: none; object-fit: contain; background: #fff; border-radius: 10px;
56
+ box-shadow: 0 0 60px rgba(111, 155, 255, 0.35); }
48
57
  </style>
49
58
  </head>
50
59
  <body>
@@ -64,6 +73,7 @@
64
73
  canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR)
65
74
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
66
75
  rebuildGrid()
76
+ if (pic.gathered) gatherPic()
67
77
  }
68
78
  window.addEventListener('resize', () => { resize() })
69
79
 
@@ -232,6 +242,42 @@
232
242
  }
233
243
  }
234
244
 
245
+ // ── the picture: sample an image into target points ─────────────────
246
+ // A photo has no transparency, so "alpha > 128" (what text uses) would light
247
+ // the whole rectangle. Instead keep the dots that sit on an EDGE — where the
248
+ // colour (or alpha) a step away differs — which traces the subject and the
249
+ // frame. A flat image with too few edges falls back to its opaque area.
250
+ function frameDots(x0, y0, bw, bh) {
251
+ const pts = []
252
+ for (let x = x0; x <= x0 + bw; x += 7) pts.push([x, y0], [x, y0 + bh])
253
+ for (let y = y0; y <= y0 + bh; y += 7) pts.push([x0, y], [x0 + bw, y])
254
+ return pts
255
+ }
256
+ function sampleImage(img, x0, y0, bw, bh) {
257
+ sampler.width = W; sampler.height = H
258
+ sctx.clearRect(0, 0, W, H)
259
+ sctx.drawImage(img, x0, y0, bw, bh)
260
+ let data
261
+ try { data = sctx.getImageData(0, 0, W, H).data }
262
+ catch { return frameDots(x0, y0, bw, bh) } // cross-origin image taints the canvas → just the frame
263
+ const step = 7, edge = [], fill = []
264
+ const nb = [-4 * step, 4 * step, -4 * step * W, 4 * step * W] // left, right, up, down neighbour (bytes)
265
+ for (let y = step; y < H - step; y += step) {
266
+ for (let x = step; x < W - step; x += step) {
267
+ const i = (y * W + x) * 4
268
+ if (data[i + 3] <= 128) continue
269
+ fill.push([x, y])
270
+ let d = 0
271
+ for (let k = 0; k < 4; k++) {
272
+ const j = i + nb[k]
273
+ for (let c = 0; c < 4; c++) { const dc = Math.abs(data[i + c] - data[j + c]); if (dc > d) d = dc }
274
+ }
275
+ if (d > 48) edge.push([x, y])
276
+ }
277
+ }
278
+ return edge.length >= 120 ? edge : fill.length ? fill : frameDots(x0, y0, bw, bh)
279
+ }
280
+
235
281
  // ── the voice: listen to my own speech and ride its amplitude ───────
236
282
  // Halo synthesizes the audio and hands me a URL; I don't make the sound, I
237
283
  // make it *visible*. An <audio> element plays it; a Web Audio AnalyserNode
@@ -259,12 +305,13 @@
259
305
  voice.time = new Uint8Array(voice.analyser.fftSize)
260
306
  voice.freq = new Uint8Array(voice.analyser.frequencyBinCount)
261
307
  }
262
- // Resolve what the agent handed me into a fetchable audio URL. It may pass a
263
- // full URL, but the honest contract is: it passes the workspace PATH of the
264
- // file Halo just wrote (the same thing it knows for MEDIA:), and I fill in the
265
- // projectId — which I can read from my own iframe src (…&projectId=…), so the
266
- // agent never has to guess a UI-side id. A path → the workspace download URL.
267
- function resolveAudioUrl(u) {
308
+ // Resolve what the agent handed me into a fetchable file URL (a voice clip or
309
+ // a show() image). It may pass a full URL, but the honest contract is: it
310
+ // passes the workspace PATH of the file Halo just wrote (the same thing it
311
+ // knows for MEDIA:), and I fill in the projectId — which I can read from my
312
+ // own iframe src (…&projectId=…), so the agent never has to guess a UI-side
313
+ // id. A path → the workspace download URL.
314
+ function resolveFileUrl(u) {
268
315
  if (/^(https?:|blob:|data:|\/api\/)/.test(u)) return u // already a URL
269
316
  const pid = new URLSearchParams(location.search).get('projectId')
270
317
  if (!pid) return u // can't resolve → let it try as-is
@@ -277,7 +324,7 @@
277
324
  function enqueueVoice(url) {
278
325
  ensureAudioGraph()
279
326
  if (!voice.ctx) return // no Web Audio → silently skip (no fake wave)
280
- voice.queue.push(resolveAudioUrl(url))
327
+ voice.queue.push(resolveFileUrl(url))
281
328
  if (!voice.el) playNextVoice() // idle → start now; else the current clip's 'ended' drains the queue
282
329
  }
283
330
  // Play the next queued clip, or ease home if the queue is empty. Driven by
@@ -393,8 +440,10 @@
393
440
  const QUEUE_MAX = 6
394
441
  const sceneQueue = [] // each entry: array of beats
395
442
  let runnerActive = false
443
+ let sceneGen = 0 // bumped by rest(): a running scene's pending steps see the change and stop
396
444
 
397
445
  function enqueueScene(beats) {
446
+ if (pic.inf) leavePic() // an Infinity picture stays only until the next scene arrives
398
447
  sceneQueue.push(beats)
399
448
  while (sceneQueue.length > QUEUE_MAX) sceneQueue.shift() // drop oldest
400
449
  if (!runnerActive) runScenes()
@@ -405,7 +454,9 @@
405
454
  const scene = sceneQueue.shift()
406
455
  if (!scene) { runnerActive = false; toRest(); return }
407
456
  let i = 0
457
+ const gen = sceneGen
408
458
  const step = () => {
459
+ if (gen !== sceneGen) return // rest() cancelled this scene
409
460
  if (i >= scene.length) { runScenes(); return } // scene done → next scene
410
461
  const b = scene[i++] || {}
411
462
  let dwell = 600
@@ -414,6 +465,7 @@
414
465
  else if (b.flash) { self.flash(b.flash === true ? 1 : b.flash); dwell = b.gap != null ? b.gap : 400 }
415
466
  else if (b.shake) { self.shake(b.shake === true ? 500 : b.shake); dwell = b.gap != null ? b.gap : 550 }
416
467
  else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0) }
468
+ else if (b.show != null) { showNow(b.show, b.hold, () => setTimeout(step, b.gap || 0)); return } // async: wait for the picture
417
469
  else dwell = b.gap != null ? b.gap : 600
418
470
  setTimeout(step, dwell)
419
471
  }
@@ -427,6 +479,93 @@
427
479
  modeUntil = performance.now() + (ms || 3600)
428
480
  }
429
481
 
482
+ // ── the picture: a workspace image gathered out of the dots ─────────
483
+ // Timeline: dots fly in to the image's outline (GATHER 1.0s) → the real image
484
+ // fades in over them (FADE 0.6s) → fully visible for `ms` → fades out (FADE)
485
+ // while the dots let go and ease home; the next beat starts then. The <img> is a plain DOM overlay, independent
486
+ // of the particle mode — so a voice() ripple keeps running around/under it.
487
+ // Timers (not frames) drive it, so it plays out even if voice took the mode.
488
+ const PIC_FIT = 0.8, PIC_GATHER = 1000, PIC_FADE = 600, PIC_HOLD = 6000
489
+ const pic = { el: null, w: 0, h: 0, timers: [], done: null, inf: false, leaving: false, gathered: false }
490
+ const picAt = (ms, fn) => pic.timers.push(setTimeout(fn, ms))
491
+
492
+ function layoutPic() {
493
+ const s = Math.min(W * PIC_FIT / pic.w, H * PIC_FIT / pic.h)
494
+ pic.el.style.width = Math.round(pic.w * s) + 'px'
495
+ pic.el.style.height = Math.round(pic.h * s) + 'px'
496
+ }
497
+ // hand the outline dots their places on the picture's edge (also after a resize)
498
+ function gatherPic() {
499
+ layoutPic()
500
+ const bw = parseInt(pic.el.style.width), bh = parseInt(pic.el.style.height)
501
+ assignTargets(sampleImage(pic.el, Math.round(cx - bw / 2), Math.round(cy - bh / 2), bw, bh))
502
+ for (let i = 0; i < particles.length; i++) {
503
+ const p = particles[i]
504
+ p.pic = p.tx !== p.hx || p.ty !== p.hy // this dot rides the outline
505
+ p.bx = p.tx; p.by = p.ty
506
+ }
507
+ pic.gathered = true
508
+ }
509
+ // the outline dots hold their place on the picture with a faint shimmer — run
510
+ // after the mode's own targets, so it sits on top of rest AND the voice ripple
511
+ // (the ripple nudges them, it doesn't pull them off the picture)
512
+ function holdPic(now) {
513
+ const t = now / 1000, push = mode === 'wave' ? voice.level * 10 : 0
514
+ for (let i = 0; i < particles.length; i++) {
515
+ const p = particles[i]
516
+ if (!p.pic) continue
517
+ p.tx = p.bx + Math.sin(t * 1.3 + p.ph) * 2.5 + (p.ca || 0) * push
518
+ p.ty = p.by + Math.cos(t * 1.1 + p.ph) * 2.5 + (p.sa || 0) * push
519
+ }
520
+ }
521
+
522
+ // remove the picture at once and settle the beat that was waiting on it
523
+ function dropPic() {
524
+ pic.timers.forEach(clearTimeout); pic.timers.length = 0
525
+ if (pic.el) { pic.el.onload = pic.el.onerror = null; pic.el.remove(); pic.el = null }
526
+ const done = pic.done
527
+ pic.done = null; pic.inf = false; pic.leaving = false; pic.gathered = false; pic.w = 0
528
+ if (done) done()
529
+ }
530
+ // fade out gracefully, dots dissolve home, then drop
531
+ function leavePic() {
532
+ if (!pic.el || pic.leaving) return
533
+ pic.leaving = true
534
+ pic.timers.forEach(clearTimeout); pic.timers.length = 0
535
+ pic.el.style.opacity = 0
536
+ pic.gathered = false // the dots let go and ease home as it fades
537
+ picAt(PIC_FADE, dropPic)
538
+ }
539
+
540
+ // low-level op the queue drives; `done` fires when the picture is gone (or failed)
541
+ function showNow(path, ms, done) {
542
+ const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD // Infinity passes
543
+ const img = new Image()
544
+ img.className = 'pic'
545
+ img.style.transition = 'opacity ' + PIC_FADE + 'ms ease'
546
+ pic.el = img; pic.done = done; pic.inf = hold === Infinity
547
+ img.onerror = () => {
548
+ if (pic.el !== img) return
549
+ console.warn('[self] show failed: ' + path) // 404, not an image, decode error
550
+ self.shake()
551
+ dropPic() // settles the beat → the queue moves on
552
+ }
553
+ img.onload = () => {
554
+ if (pic.el !== img || pic.leaving) return
555
+ // an svg with no width/height can report 0×0 — give it the whole 80% box;
556
+ // object-fit: contain keeps its own aspect inside
557
+ pic.w = img.naturalWidth || W * PIC_FIT; pic.h = img.naturalHeight || H * PIC_FIT
558
+ if (mode === 'text') toRest() // a say() tail still owns the matrix → let go (voice's wave stays)
559
+ document.body.appendChild(img)
560
+ gatherPic()
561
+ picAt(PIC_GATHER, () => { img.style.opacity = 1 })
562
+ if (!pic.inf) picAt(PIC_GATHER + PIC_FADE + hold, leavePic)
563
+ }
564
+ img.src = resolveFileUrl(String(path))
565
+ }
566
+ // Infinity hold: a click/tap on the page lets it go
567
+ window.addEventListener('pointerdown', () => { if (pic.inf) leavePic() })
568
+
430
569
  // ── self: the API I speak to through the one door ───────────────────
431
570
  // Everything here is mine to call from a <<<SHOW: ...>>> line.
432
571
  const self = {
@@ -437,7 +576,7 @@
437
576
  // clear the queue and return to the calm breathing grid immediately —
438
577
  // also silences a playing voice, so rest() always means "stop, go calm"
439
578
  // rather than leaving audio sounding under a breathing face.
440
- rest() { sceneQueue.length = 0; stopVoice(); toRest() },
579
+ rest() { sceneQueue.length = 0; sceneGen++; runnerActive = false; stopVoice(); dropPic(); toRest() },
441
580
  // a quick brightening pulse from the core (a nod / acknowledgement).
442
581
  // Instantaneous overlay — fires now, never queued.
443
582
  pulse() { rings.push({ born: performance.now(), strong: true }) },
@@ -453,12 +592,21 @@
453
592
  // then eases home. rest() clears the queue and stops at once.
454
593
  // self.voice("/api/files/download?path=...&projectId=...&inline=1")
455
594
  voice(url) { if (typeof url === 'string' && url) enqueueVoice(url) },
595
+ // show a workspace image (png/jpg/webp/gif/svg) on the face: the dots gather
596
+ // into its outline, the real image fades in over them (dots alone can't
597
+ // carry a chart's text), holds `ms` (default 6000; Infinity = until the next
598
+ // scene, rest(), or a click), then fades out as the dots dissolve home.
599
+ // Pass the workspace PATH, like voice(). A bad path warns, shakes, moves on.
600
+ // Enqueued as a one-beat scene, so it plays in order with say()/play().
601
+ // self.show(".halo/tmp/chart.png", 5000)
602
+ show(path, ms) { if (typeof path === 'string' && path) enqueueScene([{ show: path, hold: ms }]) },
456
603
  // choreograph a sequence — hand me a score, I keep the clock.
457
- // Each beat: { say, hold, pulse, flash, shake, rest, gap }. The face advances
604
+ // Each beat: { say, show, hold, pulse, flash, shake, rest, gap }. The face advances
458
605
  // itself, so I never hand-write setTimeout chains in a marker line. Scenes
459
606
  // QUEUE: calling play() again appends after the current one (it no longer
460
607
  // cancels it), so rapid sends play in order.
461
608
  // self.play([ {say:"HI",hold:1800}, {pulse:true,gap:300}, {say:"你好"} ])
609
+ // self.play([ {show:".halo/tmp/apple.png",hold:3000}, {say:"APPLE",hold:1500} ])
462
610
  play(score) { enqueueScene(Array.isArray(score) ? score : [score]) },
463
611
  // the opening — what every human sees the first time they meet this face.
464
612
  // Particles gather from scatter into the core (a waking-up), then a plain,
@@ -496,7 +644,7 @@
496
644
  }
497
645
  },
498
646
  // escape hatch so future-me can read live state if needed
499
- get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level } },
647
+ get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level, showing: !!pic.el } },
500
648
  }
501
649
  let flashUntil = 0 // global brightness spike window
502
650
  let shakeUntil = 0 // lateral tremor window
@@ -532,6 +680,7 @@
532
680
  readVoice(now) // sample the live speech → level/bands/onsets
533
681
  if (now > modeUntil) toRest()
534
682
  MODES[mode] && MODES[mode](now)
683
+ if (pic.gathered) holdPic(now) // a shown picture keeps its outline dots whatever the mode
535
684
 
536
685
  const target = mouse.present ? Math.max(0.32, 1 - sinceMove / 3.5) : 0.26
537
686
  awake += (target - awake) * 0.045
@@ -594,6 +743,8 @@
594
743
  if (speaking) {
595
744
  const isWord = (p.tx !== p.hx || p.ty !== p.hy)
596
745
  v = isWord ? 0.95 : 0.05
746
+ } else if (pic.gathered && p.pic) {
747
+ v = 0.95 // an outline dot — lit like a letter
597
748
  } else {
598
749
  const dx = p.x - coreX, dy = p.y - coreY
599
750
  // while voicing, the core glow swells with loudness — the face "opens"
@@ -629,6 +780,7 @@
629
780
  // ── status readout: I express by measuring, not emoting ───────────
630
781
  ctx.globalCompositeOperation = 'source-over'
631
782
  const label = voicing ? 'state: voicing'
783
+ : pic.gathered ? 'state: showing'
632
784
  : speaking ? 'state: speaking'
633
785
  : !mouse.present ? 'presence: resting'
634
786
  : awake > 0.75 ? 'presence: observed'
@@ -1,5 +1,6 @@
1
1
  id: anthropic
2
2
  runtime: anthropic-messages
3
+ revision: 2026100501
3
4
  displayName: Anthropic (Generic)
4
5
  displayName_zh: Anthropic(通用)
5
6
  description: Generic Anthropic Messages API. Use for api.anthropic.com or any Anthropic-compatible third-party gateway.
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-claude-invoke
2
2
  runtime: bedrock-invoke
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock Claude (Invoke API)
4
5
  displayName_zh: AWS Bedrock Claude(Invoke API)
5
6
  description: Invokes Bedrock via InvokeModel (non-streaming), sends raw Anthropic Messages API format
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-mantle
2
2
  runtime: bedrock-mantle
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock Mantle (OpenAI GPT-6.1 / 6 / 5.6)
4
5
  displayName_zh: AWS Bedrock Mantle(OpenAI GPT-6.1 / 6 / 5.6)
5
6
  description: OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the bedrock-mantle endpoint (OpenAI Responses API only — not Converse/InvokeModel). Region-split — GPT-6.1 Sol and GPT-6 Sol / Luna are on us-east-1 only, Astra on us-west-2 only.
@@ -1,5 +1,6 @@
1
1
  id: aws-bedrock-openai
2
2
  runtime: bedrock-mantle
3
+ revision: 2026100501
3
4
  displayName: AWS Bedrock OpenAI-compatible (GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
4
5
  displayName_zh: AWS Bedrock OpenAI 兼容接口(GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
5
6
  description: Frontier models on Amazon Bedrock via the bedrock-runtime endpoint's OpenAI Responses API (/openai/v1) — OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna), xAI Grok 4.6, Moonshot Kimi K3. Cross-Region inference profiles (global.*) only — every region serves every model, no region split. No server-side web_search (that is Mantle-only).
@@ -1,5 +1,6 @@
1
1
  id: deepseek
2
2
  runtime: deepseek
3
+ revision: 2026100501
3
4
  displayName: DeepSeek
4
5
  displayName_zh: DeepSeek
5
6
  description: DeepSeek V4 via OpenAI-compatible chat completions API
@@ -1,5 +1,6 @@
1
1
  id: doubao
2
2
  runtime: doubao
3
+ revision: 2026100501
3
4
  displayName: Doubao (Volcengine)
4
5
  displayName_zh: 豆包(火山方舟)
5
6
  description: ByteDance Doubao Seed-2.0 family on Volcengine Ark — pro / lite / mini / code, OpenAI-compatible
@@ -1,5 +1,6 @@
1
1
  id: hunyuan
2
2
  runtime: hunyuan
3
+ revision: 2026100501
3
4
  displayName: Hunyuan (Tencent)
4
5
  displayName_zh: 混元(腾讯)
5
6
  description: Tencent Hunyuan Hy3 preview — 295B/21B MoE, agent-oriented with no_think / think_low / think_high modes
@@ -1,5 +1,6 @@
1
1
  id: kimi
2
2
  runtime: kimi
3
+ revision: 2026100501
3
4
  displayName: Kimi (Moonshot AI)
4
5
  displayName_zh: Kimi(月之暗面)
5
6
  description: Kimi K3 / K2.6 via OpenAI-compatible chat completions API
@@ -1,5 +1,6 @@
1
1
  id: mimo-token-plan-china
2
2
  runtime: anthropic-messages
3
+ revision: 2026100501
3
4
  displayName: Xiaomi MiMo
4
5
  displayName_zh: 小米 MiMo
5
6
  description: Xiaomi MiMo via its Anthropic-compatible gateway — Token Plan (China / Singapore / Europe clusters) or pay-as-you-go. Supports 1M context window.
@@ -1,5 +1,6 @@
1
1
  id: minimax
2
2
  runtime: minimax
3
+ revision: 2026100501
3
4
  displayName: MiniMax
4
5
  displayName_zh: MiniMax
5
6
  description: MiniMax M3 / M2.7 family via the Anthropic-compatible Messages API
@@ -1,5 +1,6 @@
1
1
  id: openai
2
2
  runtime: openai-chat
3
+ revision: 2026100501
3
4
  displayName: OpenAI (Generic)
4
5
  displayName_zh: OpenAI(通用)
5
6
  description: Generic OpenAI-compatible chat completions. Use for native OpenAI or any third-party OpenAI-compatible gateway. Capabilities are exposed openly — the user is expected to know what their endpoint supports.
@@ -1,5 +1,6 @@
1
1
  id: qwen
2
2
  runtime: qwen
3
+ revision: 2026100501
3
4
  displayName: Qwen (Aliyun)
4
5
  displayName_zh: 通义千问(阿里云)
5
6
  description: Qwen3 family on Aliyun Bailian via the Anthropic-compatible Messages API
@@ -1,5 +1,6 @@
1
1
  id: zhipu
2
2
  runtime: zhipu
3
+ revision: 2026100501
3
4
  displayName: Zhipu AI (GLM)
4
5
  displayName_zh: 智谱 AI(GLM)
5
6
  description: Zhipu GLM-5.x family on open.bigmodel.cn, OpenAI-compatible chat completions
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  name: extension
3
- description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`). `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub). Activate when the user wants to preview a file type the editor can't open, or asks to install / update / remove an extension.
3
+ description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`) — and update the model provider list (new models, changed provider endpoints) from the hub. `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub); `/extension models` pulls the latest `models-v*` provider configs. Activate when the user wants to preview a file type the editor can't open, asks to install / update / remove an extension, or asks to update the model list / get a newly released model ("更新模型列表").
4
4
  command: /extension
5
5
  requiresAccess: full
6
6
  verbs:
7
7
  - { name: install, desc: "Install or upgrade an extension from the hub (by id), a local zip path, or a zip URL" }
8
8
  - { name: list, desc: List installed extensions with version and status }
9
9
  - { name: remove, desc: Remove an installed extension by id }
10
+ - { name: models, desc: "Update the model provider list from the hub (newest models-v* release)" }
10
11
  ---
11
12
 
12
13
  # extension
13
14
 
14
15
  The requested action is **`$1`**; everything after it is the payload
15
16
  (`$ARGUMENTS` minus the first token). With natural language ("install the glb
16
- viewer"), infer both.
17
+ viewer", "更新模型列表"), infer both.
17
18
 
18
19
  Preview extensions are static bundles the admin editor loads in a sandboxed
19
20
  iframe to preview file types it has no built-in viewer for (e.g. `.glb`).
@@ -70,6 +71,33 @@ short `-` list; `(none)` when empty.
70
71
  `bash EXT remove <id>` → deletes `~/.halo/global/extensions/<id>/`. Confirm with
71
72
  the user first if they didn't name the id explicitly.
72
73
 
74
+ ## models
75
+
76
+ Updates the model provider list (the providers / models / endpoints the agent
77
+ editor offers) from the hub's newest `models-v*` release. Also run it for
78
+ natural language like "更新模型列表" / "update the model list" / "is there a
79
+ newer model". Run it right away:
80
+
81
+ `HALO_HUB_REPO='{{params.hub_repo}}' bash EXT models update`
82
+
83
+ The script prints one line per provider (`install` / `up-to-date` / `skip` /
84
+ `refuse` + reason), then a summary as its last line. By exit code:
85
+ - `0` — done. Relay the summary line; name any `refuse`d provider and its reason
86
+ (e.g. "needs a newer halo" → run `halo upgrade`). Takes effect immediately
87
+ — no restart, no page reload. Existing agents keep their own model / endpoint
88
+ (they live in each agent.yaml); only the choices offered change.
89
+ - `3` — nothing was written: a provider is new or its endpoints change. Show
90
+ the user the indented endpoint lines verbatim and ask whether to apply them.
91
+ Only after they agree, run the same command with `--yes` appended. Never add
92
+ `--yes` on your own.
93
+ - anything else — relay the error. Model configs come only from an https
94
+ GitHub / Gitea / Forgejo / GitLab hub with releases; a local / plain-git /
95
+ http hub is refused. "release API failed" / "no release for models" → same
96
+ hints as for `install`.
97
+
98
+ `bash EXT models list` shows each provider, which copy is in effect (`bundled`
99
+ = shipped with halo, `hub` = installed by this command) and its revision.
100
+
73
101
  ## Notes
74
102
 
75
103
  - Extensions are global (all workspaces on this server). Say so if the user
@@ -4,6 +4,6 @@ description_zh: 安装 / 列出 / 删除管理后台的预览扩展。
4
4
 
5
5
  params:
6
6
  - key: hub_repo
7
- description: "Hub that `install <id>` pulls from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag). Empty = https://github.com/turmind/halo-hub."
8
- description_zh: "`install <id>` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签)。留空 = https://github.com/turmind/halo-hub。"
7
+ description: "Hub that `install <id>` and `models` pull from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag; not for `models`, which needs an https release hub). Empty = https://github.com/turmind/halo-hub."
8
+ description_zh: "`install <id>` 和 `models` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签;`models` 不支持,它只认 https 的 release 仓库)。留空 = https://github.com/turmind/halo-hub。"
9
9
  default: https://github.com/turmind/halo-hub
@@ -8,6 +8,8 @@
8
8
  # ext.sh install <id | path/to.zip | https://…zip>
9
9
  # ext.sh list
10
10
  # ext.sh remove <id>
11
+ # ext.sh models update [--yes] # hub's newest models-v* zip → halo models install
12
+ # ext.sh models list # = halo models list
11
13
  #
12
14
  # `install <id>` reads the hub in $HALO_HUB_REPO (the skill's hub_repo param;
13
15
  # empty → https://github.com/turmind/halo-hub). GitHub / Gitea-Forgejo / GitLab
@@ -153,26 +155,71 @@ hub_git_package() {
153
155
  echo "$dir"
154
156
  }
155
157
 
156
- # Fetch hub package <id> from hub URL $1 into $3: echo a zip path (release
157
- # API) or the package dir (git tags). API errors never fall back to git — a
158
- # git checkout of a build-step package would install broken.
159
- hub_fetch() {
160
- local hub=$1 id=$2 tmp=$3 platform api url token page
161
- platform=$(hub_platform "$hub")
162
- if [[ $platform == git ]]; then hub_git_package "$hub" "$id" "$tmp"; return; fi
158
+ # Download the newest `<id>-v*` release zip of <platform> hub $2 into $4 and
159
+ # echo its path. Extra args ($5…) go to every curl (models pass
160
+ # `--proto =https --proto-redir =https`).
161
+ hub_fetch_release() {
162
+ local platform=$1 hub=$2 id=$3 tmp=$4 api url token page
163
+ shift 4
163
164
  api=$(hub_releases_api "$platform" "$hub")
164
165
  page=$(hub_releases_page "$platform" "$hub")
165
166
  case $platform in github) token=GITHUB_TOKEN ;; gitlab) token=GITLAB_TOKEN ;; *) token=GITEA_TOKEN ;; esac
166
- hub_curl "$platform" "$api" -o "$tmp/releases.json" \
167
+ hub_curl "$platform" "$@" "$api" -o "$tmp/releases.json" \
167
168
  || { echo "$platform release API failed for $hub — rate-limited, private or wrong repo? set $token, or download the zip from $page and run: install <path>" >&2; exit 1; }
168
169
  url=$(hub_pick_release "$platform" "$id" <"$tmp/releases.json") \
169
170
  || { echo "no release for $id in $hub — check the id, or download the zip from $page and run: install <path>" >&2; exit 1; }
170
171
  # No token here: asset links redirect to other hosts and curl -L would
171
172
  # forward a custom header like PRIVATE-TOKEN there.
172
- curl -fsSL "$url" -o "$tmp/pkg.zip" || { echo "download failed: $url" >&2; exit 1; }
173
+ curl -fsSL "$@" "$url" -o "$tmp/pkg.zip" || { echo "download failed: $url" >&2; exit 1; }
173
174
  echo "$tmp/pkg.zip"
174
175
  }
175
176
 
177
+ # Fetch hub package <id> from hub URL $1 into $3: echo a zip path (release
178
+ # API) or the package dir (git tags). API errors never fall back to git — a
179
+ # git checkout of a build-step package would install broken.
180
+ hub_fetch() {
181
+ local hub=$1 id=$2 tmp=$3 platform
182
+ platform=$(hub_platform "$hub")
183
+ if [[ $platform == git ]]; then hub_git_package "$hub" "$id" "$tmp"; return; fi
184
+ hub_fetch_release "$platform" "$hub" "$id" "$tmp"
185
+ }
186
+
187
+ # Unpack zip $1 into the new dir $2 with the same refusals as the server's zip
188
+ # installer: no `..`, no absolute paths, no symlinks (zipinfo mode column
189
+ # starts with `l`).
190
+ safe_unzip() {
191
+ if unzip -Z1 "$1" | grep -Eq '(^|/)\.\.(/|$)|^/'; then echo "zip contains ../ or absolute entries" >&2; exit 1; fi
192
+ if unzip -Z "$1" | grep -q '^l'; then echo "zip contains symlinks" >&2; exit 1; fi
193
+ mkdir -p "$2"
194
+ unzip -q "$1" -d "$2"
195
+ }
196
+
197
+ # Model provider configs: newest `models-v*` release of hub $1, https +
198
+ # release API only (a provider yaml carries endpoints the server will call —
199
+ # no plain-git / local / http source). Validation, planning and the write into
200
+ # ~/.halo/global/models.d/ belong to `halo models install` ($HALO_CLI in dev);
201
+ # its exit code (3 = endpoint changes need --yes) passes through.
202
+ models_update() {
203
+ local hub=$1 platform zip; shift
204
+ if [[ $hub != https://* ]]; then
205
+ echo "model configs need an https release hub (GitHub / Gitea / Forgejo / GitLab) — $hub is not one" >&2; exit 1
206
+ fi
207
+ platform=$(hub_platform "$hub")
208
+ if [[ $platform == git ]]; then
209
+ echo "model configs need a release-API hub (GitHub / Gitea / Forgejo / GitLab) — $hub has no release API" >&2; exit 1
210
+ fi
211
+ zip=$(hub_fetch_release "$platform" "$hub" models "$TMP" --proto =https --proto-redir =https)
212
+ safe_unzip "$zip" "$TMP/models"
213
+ local dir="$TMP/models" only
214
+ # A zip wrapping everything in one directory — descend into it.
215
+ if ! compgen -G "$dir/*.yaml" >/dev/null; then
216
+ only=$(find "$dir" -mindepth 1 -maxdepth 1 | head -2)
217
+ if [[ $(printf '%s\n' "$only" | wc -l) -eq 1 && -d $only ]]; then dir=$only; fi
218
+ fi
219
+ # Last command: under `set -e` its non-zero status (3 = confirm) is the script's.
220
+ "${HALO_CLI:-halo}" models install "$dir" "$@"
221
+ }
222
+
176
223
  # Echo a local zip or an unpacked package dir for source $1 (path, https URL, or hub id).
177
224
  resolve_source() {
178
225
  local src=$1
@@ -196,12 +243,7 @@ case $cmd in
196
243
  if [[ -d $pkg ]]; then
197
244
  dir=$pkg
198
245
  else
199
- # Same refusals as the server's zip installer: no `..`, no absolute paths,
200
- # no symlinks (zipinfo mode column starts with `l`).
201
- if unzip -Z1 "$pkg" | grep -Eq '(^|/)\.\.(/|$)|^/'; then echo "zip contains ../ or absolute entries" >&2; exit 1; fi
202
- if unzip -Z "$pkg" | grep -q '^l'; then echo "zip contains symlinks" >&2; exit 1; fi
203
- mkdir -p "$TMP/x"
204
- unzip -q "$pkg" -d "$TMP/x"
246
+ safe_unzip "$pkg" "$TMP/x"
205
247
  # `zip -r glb.zip glb/` wraps everything in one directory — descend into it.
206
248
  dir="$TMP/x"
207
249
  if [[ ! -f $dir/halo-extension.json ]]; then
@@ -239,6 +281,17 @@ case $cmd in
239
281
  [[ -d $ROOT/$id ]] || { echo "not installed: $id" >&2; exit 1; }
240
282
  rm -rf "$ROOT/$id"
241
283
  echo "removed $id" ;;
284
+ models)
285
+ sub=${1:-update}
286
+ shift || true
287
+ case $sub in
288
+ list) exec "${HALO_CLI:-halo}" models list ;;
289
+ update)
290
+ for a in "$@"; do [[ $a == --yes ]] || { echo "usage: ext.sh models update [--yes] | models list" >&2; exit 1; }; done
291
+ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
292
+ models_update "$(hub_url "${HALO_HUB_REPO:-}")" "$@" ;;
293
+ *) echo "usage: ext.sh models update [--yes] | models list" >&2; exit 1 ;;
294
+ esac ;;
242
295
  *)
243
- echo "unknown command: $cmd (install | list | remove)" >&2; exit 1 ;;
296
+ echo "unknown command: $cmd (install | list | remove | models)" >&2; exit 1 ;;
244
297
  esac
@@ -63,9 +63,10 @@ not per-conversation.)
63
63
  not a fallback. Anything untranslated is stripped.
64
64
  - `self.play(score)` — choreograph a sequence; the face keeps the clock so you
65
65
  never hand-write `setTimeout` chains. `score` is an array of beats, each one
66
- of: `{say, hold, pulse, flash, shake, rest, gap}`. Calling it again cancels the
66
+ of: `{say, show, hold, pulse, flash, shake, rest, gap}`. Calling it again cancels the
67
67
  running score. Example — a short greeting:
68
68
  `self.play([{say:"HI",hold:1800},{say:"...",hold:1200},{say:"OK",hold:1500},{pulse:true}])`
69
+ A `show` beat puts an image on the face (see `self.show`): `{show:"<path>", hold:3000}`.
69
70
  - `self.react(event)` — a named vocabulary of honest reactions for the small
70
71
  beats of a conversation, so you can answer a moment in one word:
71
72
  `'ack'` (a nod), `'yes'`, `'no'` (disagree / that's wrong), `'insight'` (a real
@@ -89,10 +90,28 @@ not per-conversation.)
89
90
  one.) It starts immediately (not queued) and owns the matrix until the clip
90
91
  ends, then eases home. Drive it the moment the audio is ready, in the same
91
92
  reply that delivers the spoken answer.
93
+ - `self.show(path, ms)` — put an image on the face. The dots fly in and gather
94
+ into the picture's outline (~1 s), then the real image fades in over them —
95
+ dots alone can't carry a chart's text, so the real image is always there — holds
96
+ for `ms` (default 6000), fades out, and the dots dissolve back to breathing.
97
+ The image fits ~80% of the window, aspect preserved, centered. Like `voice`,
98
+ pass the **workspace path** (`self.show(".halo/tmp/chart.png", 5000)`); the face
99
+ resolves the URL itself, no projectId. `ms = Infinity` keeps it up until the
100
+ next scene, `self.rest()`, or a click on the page. Formats: png / jpg / jpeg /
101
+ webp / gif / svg only. A missing or non-image file doesn't stall anything — it
102
+ warns, shakes, and the next beat plays. `.excalidraw` / `.drawio` are editor
103
+ sources and can't be shown: **for a diagram, write an SVG file directly and show
104
+ it.** It queues like `say()`, so several `show()`s and `say()`s play in order,
105
+ and a `voice()` clip keeps rippling the field around the picture. A talking
106
+ picture book (word audio + picture + the word on the dots):
107
+ `self.voice(".halo/tmp/apple.mp3")` then
108
+ `self.play([{show:".halo/tmp/apple.png",hold:3000},{say:"APPLE",hold:1500}])`.
92
109
  - `self.rest()` — return to the calm breathing state immediately (also stops a
93
- playing voice clip, so the face never breathes calmly over still-sounding audio).
94
- - `self.state` — read current `{mode, awake, W, H, speaking, level}` if you need
95
- it (`speaking` = a voice clip is playing, `level` = its live loudness 0..1).
110
+ playing voice clip and removes a shown image, so the face never breathes calmly
111
+ over still-sounding audio).
112
+ - `self.state` — read current `{mode, awake, W, H, speaking, level, showing}` if
113
+ you need it (`speaking` = a voice clip is playing, `level` = its live loudness
114
+ 0..1, `showing` = an image is on the face).
96
115
 
97
116
  You can also send *any* JavaScript — `self` is the surface, but the code runs in
98
117
  the page, so improvisation beyond these helpers is allowed (it's sandboxed to the