@turmind/halo 1.5.6 → 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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/989-df2401d289d09a7e.js +1 -0
- package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/design/agent.md +7 -7
- package/bundled-docs/design/architecture.md +13 -11
- package/bundled-docs/design/canvas-extensions.md +3 -1
- package/bundled-docs/design/express-self.md +16 -7
- package/bundled-docs/design/storage.md +11 -3
- package/bundled-docs/design/ws.md +1 -0
- package/bundled-docs/dev/add-model-provider.md +45 -27
- package/bundled-docs/dev/deploy.md +1 -1
- package/bundled-docs/dev/desktop-packaging.md +4 -3
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/guide/agents.md +3 -3
- package/bundled-docs/guide/cli.md +15 -5
- package/bundled-docs/guide/getting-started.md +1 -1
- package/bundled-docs/requirements/chat.md +2 -2
- package/bundled-docs/requirements/settings.md +2 -1
- package/dist/index.js +1545 -1152
- package/package.json +1 -1
- package/templates/canvas/self.html +162 -10
- package/templates/models/anthropic.yaml +2 -0
- package/templates/models/aws-bedrock-claude-invoke.yaml +2 -0
- package/templates/models/aws-bedrock-mantle.yaml +2 -0
- package/templates/models/aws-bedrock-openai.yaml +2 -0
- package/templates/models/deepseek.yaml +2 -0
- package/templates/models/doubao.yaml +2 -0
- package/templates/models/hunyuan.yaml +2 -0
- package/templates/models/kimi.yaml +2 -0
- package/templates/models/mimo-token-plan-china.yaml +2 -0
- package/templates/models/minimax.yaml +2 -0
- package/templates/models/openai.yaml +2 -0
- package/templates/models/qwen.yaml +2 -0
- package/templates/models/zhipu.yaml +2 -0
- package/templates/skills/extension/SKILL.md +30 -2
- package/templates/skills/extension/config.yaml +2 -2
- package/templates/skills/extension/templates/ext.sh +69 -16
- package/templates/skills/self/SKILL.md +23 -4
- package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
- package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
- /package/admin-out/_next/static/{visjvN510yFK-Ff5zwx-0 → T3x-q3QCOgSpfydoH53XD}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{visjvN510yFK-Ff5zwx-0 → T3x-q3QCOgSpfydoH53XD}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -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
|
|
263
|
-
// full URL, but the honest contract is: it
|
|
264
|
-
// file Halo just wrote (the same thing it
|
|
265
|
-
// projectId — which I can read from my
|
|
266
|
-
// agent never has to guess a UI-side
|
|
267
|
-
|
|
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(
|
|
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,4 +1,6 @@
|
|
|
1
1
|
id: aws-bedrock-claude-invoke
|
|
2
|
+
runtime: bedrock-invoke
|
|
3
|
+
revision: 2026100501
|
|
2
4
|
displayName: AWS Bedrock Claude (Invoke API)
|
|
3
5
|
displayName_zh: AWS Bedrock Claude(Invoke API)
|
|
4
6
|
description: Invokes Bedrock via InvokeModel (non-streaming), sends raw Anthropic Messages API format
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
id: aws-bedrock-mantle
|
|
2
|
+
runtime: bedrock-mantle
|
|
3
|
+
revision: 2026100501
|
|
2
4
|
displayName: AWS Bedrock Mantle (OpenAI GPT-6.1 / 6 / 5.6)
|
|
3
5
|
displayName_zh: AWS Bedrock Mantle(OpenAI GPT-6.1 / 6 / 5.6)
|
|
4
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,4 +1,6 @@
|
|
|
1
1
|
id: aws-bedrock-openai
|
|
2
|
+
runtime: bedrock-mantle
|
|
3
|
+
revision: 2026100501
|
|
2
4
|
displayName: AWS Bedrock OpenAI-compatible (GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
|
|
3
5
|
displayName_zh: AWS Bedrock OpenAI 兼容接口(GPT-6.1 / 6 / 5.6 · Grok 4.6 · Kimi K3)
|
|
4
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,4 +1,6 @@
|
|
|
1
1
|
id: mimo-token-plan-china
|
|
2
|
+
runtime: anthropic-messages
|
|
3
|
+
revision: 2026100501
|
|
2
4
|
displayName: Xiaomi MiMo
|
|
3
5
|
displayName_zh: 小米 MiMo
|
|
4
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,4 +1,6 @@
|
|
|
1
1
|
id: openai
|
|
2
|
+
runtime: openai-chat
|
|
3
|
+
revision: 2026100501
|
|
2
4
|
displayName: OpenAI (Generic)
|
|
3
5
|
displayName_zh: OpenAI(通用)
|
|
4
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,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,
|
|
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>`
|
|
8
|
-
description_zh: "`install <id>` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>`
|
|
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
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
local
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
-
|
|
95
|
-
|
|
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
|