@threadcrumbs/threadnauts 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <meta name="color-scheme" content="dark" />
7
7
  <title>Threadnauts</title>
8
8
  <link rel="icon" href="/favicon.svg?app=threadnauts" type="image/svg+xml" />
9
- <script type="module" crossorigin src="/assets/index-BSPNlXcc.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/shared-CgqOYNxw.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-DoVfuOrP.css">
9
+ <script type="module" crossorigin src="/assets/index-CASWItKa.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/shared-CudSvZVV.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BzSHs41z.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="app"><div class="boot"><div class="inner">
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "files": [
3
3
  {
4
- "path": "assets/pro-Cb7jFyCP.js",
5
- "sha256": "83dcd0c67e85a2e5b2d8130f86afda3291e10f34e6727ac5157f2ee4dfc72a4b",
6
- "bytes": 188138
4
+ "path": "assets/pro-DirsrB9B.js",
5
+ "sha256": "0040bc8fe354c96a4a81d290bde4a027502f9ea6a42ebe754dccce5c31f2c7df",
6
+ "bytes": 261373
7
7
  }
8
8
  ]
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/threadnauts",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "A 3D colony sim where every coding-agent thread on your machine is a little bot building something.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -49,10 +49,11 @@
49
49
  "icons": "node tools/build-world-icons.mjs",
50
50
  "site": "node tools/build-site.mjs",
51
51
  "test": "node --test \"test/**/*.test.mjs\"",
52
- "prepack": "pnpm run build"
52
+ "prepack": "pnpm run build",
53
+ "auto-publish": "node ../common/tools/release.mjs"
53
54
  },
54
55
  "dependencies": {
55
- "@threadcrumbs/common": "^0.1.0"
56
+ "@threadcrumbs/common": "^0.1.7"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@gltf-transform/core": "^4.4.2",
@@ -0,0 +1,27 @@
1
+ const HEX_COLOR = /^#[0-9a-f]{6}$/
2
+
3
+ const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {})
4
+
5
+ function asHex(value) {
6
+ if (Number.isInteger(value) && value >= 0 && value <= 0xffffff) return `#${value.toString(16).padStart(6, '0')}`
7
+ if (typeof value === 'string' && HEX_COLOR.test(value.toLowerCase())) return value.toLowerCase()
8
+ return null
9
+ }
10
+
11
+ function hexColors(...layers) {
12
+ const out = {}
13
+ for (const layer of layers) {
14
+ for (const [name, value] of Object.entries(asObject(layer))) {
15
+ const hex = asHex(value)
16
+ if (hex) Object.defineProperty(out, name, { value: hex, enumerable: true, writable: true, configurable: true })
17
+ }
18
+ }
19
+ return out
20
+ }
21
+
22
+ export function accentsAnswer(state) {
23
+ return {
24
+ orgs: hexColors(state?.accentsShown?.orgs, state?.accents?.orgs),
25
+ repos: hexColors(state?.accentsShown?.plots, state?.accents?.plots),
26
+ }
27
+ }
package/server/api.mjs CHANGED
@@ -8,7 +8,8 @@ import { openInTerminal } from './lib/terminal.mjs'
8
8
  import { focusWindowOfPid } from './lib/windows.mjs'
9
9
  import { localLicensing } from './license.mjs'
10
10
  import { localProContent } from './pro-content.mjs'
11
- import { findThreadgauge, threadgaugeContexts } from './threadgauge.mjs'
11
+ import { findThreadgauge, threadgaugeSessions } from './threadgauge.mjs'
12
+ import { accentsAnswer } from './accents.mjs'
12
13
  import { organizationReader } from '@threadcrumbs/common/orgs'
13
14
  import {
14
15
  defaultHarness,
@@ -35,7 +36,7 @@ const proContent = () => (proFiles ??= localProContent())
35
36
  export const proContentFile = (pathname) => proContent().file(pathname)
36
37
 
37
38
  const withOrganizations = organizationReader()
38
- const contextsFromThreadgauge = threadgaugeContexts()
39
+ const sessionsFromThreadgauge = threadgaugeSessions()
39
40
 
40
41
  let about = null
41
42
  const aboutThisApp = () =>
@@ -82,6 +83,7 @@ const emptyState = () => ({
82
83
  viewedAt: {},
83
84
  trinkets: {},
84
85
  accents: {},
86
+ accentsShown: {},
85
87
  districts: 0,
86
88
  settings: null,
87
89
  updatedAt: 0,
@@ -90,9 +92,26 @@ const emptyState = () => ({
90
92
  const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {})
91
93
  const asArray = (v) => (Array.isArray(v) ? v : [])
92
94
 
95
+ const BUSY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
96
+ const BUSY_WAITS_MS = [50, 150, 400]
97
+ const BUSY_MESSAGE = 'Another program has colony.json open. Threadnauts will try again.'
98
+ const isBusy = (err) => BUSY_CODES.has(err?.code)
99
+
100
+ async function whileBusy(operation) {
101
+ for (const wait of BUSY_WAITS_MS) {
102
+ try {
103
+ return await operation()
104
+ } catch (err) {
105
+ if (!isBusy(err)) throw err
106
+ await new Promise((resolve) => setTimeout(resolve, wait))
107
+ }
108
+ }
109
+ return operation()
110
+ }
111
+
93
112
  async function readState() {
94
113
  try {
95
- const raw = migrate(JSON.parse(await fsp.readFile(STATE_FILE, 'utf8')))
114
+ const raw = migrate(JSON.parse(await whileBusy(() => fsp.readFile(STATE_FILE, 'utf8'))))
96
115
  return {
97
116
  version: STATE_VERSION,
98
117
  archived: asArray(raw.archived),
@@ -105,11 +124,13 @@ async function readState() {
105
124
  viewedAt: asObject(raw.viewedAt),
106
125
  trinkets: asObject(raw.trinkets),
107
126
  accents: asObject(raw.accents),
127
+ accentsShown: asObject(raw.accentsShown),
108
128
  districts: Number(raw.districts) || 0,
109
129
  settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
110
130
  updatedAt: Number(raw.updatedAt) || 0,
111
131
  }
112
- } catch {
132
+ } catch (err) {
133
+ if (isBusy(err)) throw err
113
134
  return emptyState()
114
135
  }
115
136
  }
@@ -144,6 +165,7 @@ async function writeState(next) {
144
165
  viewedAt: asObject(next.viewedAt),
145
166
  trinkets: asObject(next.trinkets),
146
167
  accents: asObject(next.accents),
168
+ accentsShown: asObject(next.accentsShown),
147
169
  districts: Number(next.districts) || 0,
148
170
  settings: next.settings && typeof next.settings === 'object' ? next.settings : null,
149
171
  updatedAt: Date.now(),
@@ -154,7 +176,7 @@ async function writeState(next) {
154
176
  // colony.json names the repos you work in and where they are: the owner's business, not
155
177
  // every account on the machine's. A fresh file per write, so the mode always applies.
156
178
  await fsp.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
157
- await fsp.rename(tmp, STATE_FILE)
179
+ await whileBusy(() => fsp.rename(tmp, STATE_FILE))
158
180
  } catch (err) {
159
181
  await fsp.rm(tmp, { force: true }).catch(() => {})
160
182
  throw err
@@ -403,10 +425,26 @@ function isLocalRequest(req) {
403
425
  if (!LOCAL_HOSTS.has(hostnameOf(req.headers.host))) return false
404
426
 
405
427
  const origin = req.headers.origin
406
- if (origin && origin !== 'null') return LOCAL_HOSTS.has(hostnameOf(origin))
428
+ if (origin && origin !== 'null') return isOwnOrigin(origin, req)
407
429
  return req.method === 'GET' || req.method === 'HEAD'
408
430
  }
409
431
 
432
+ function isOwnOrigin(origin, req) {
433
+ try {
434
+ const url = new URL(origin)
435
+ const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80)
436
+ return LOCAL_HOSTS.has(hostnameOf(origin)) && port === req.socket.localPort
437
+ } catch {
438
+ return false
439
+ }
440
+ }
441
+
442
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
443
+ const sendsJson = (req) => /^application\/json\s*(;|$)/i.test(req.headers['content-type'] || '')
444
+
445
+ const LOOPBACK_PEER = /^(127\.|::1$|::ffff:127\.)/
446
+ export const isAnotherDevice = (req) =>!LOOPBACK_PEER.test(req.socket.remoteAddress || '')
447
+
410
448
  function readJsonBody(req, limit = 4 * 1024 * 1024) {
411
449
  return new Promise((resolve, reject) => {
412
450
  let size = 0
@@ -438,6 +476,9 @@ export async function middleware(req, res, next) {
438
476
  if (!isLocalRequest(req)) {
439
477
  return send(res, 403, { error: 'Threadnauts only answers its own page on this machine' })
440
478
  }
479
+ if (WRITE_METHODS.has(req.method) && !sendsJson(req)) {
480
+ return send(res, 415, { error: 'Threadnauts only accepts JSON' })
481
+ }
441
482
 
442
483
  try {
443
484
  if (url.pathname === '/api/threads' && req.method === 'GET') {
@@ -477,7 +518,7 @@ export async function middleware(req, res, next) {
477
518
  if (url.pathname === '/api/state' && req.method === 'PUT') {
478
519
  const body = await readJsonBody(req)
479
520
  const base = Number(body.baseUpdatedAt) || 0
480
- return serialise(async () => {
521
+ return await serialise(async () => {
481
522
  const current = await readState()
482
523
  if (base && current.updatedAt !== base) return send(res, 409, current)
483
524
  return send(res, 200, await writeState(body))
@@ -488,12 +529,16 @@ export async function middleware(req, res, next) {
488
529
  return send(res, 200, await aboutThisApp())
489
530
  }
490
531
 
532
+ if (url.pathname === '/api/accents' && req.method === 'GET') {
533
+ return send(res, 200, accentsAnswer(await readState()))
534
+ }
535
+
491
536
  if (url.pathname === '/api/threadgauge' && req.method === 'GET') {
492
537
  return send(res, 200, { url: await findThreadgauge() })
493
538
  }
494
539
 
495
- if (url.pathname === '/api/threadgauge/contexts' && req.method === 'GET') {
496
- return send(res, 200, await contextsFromThreadgauge())
540
+ if (url.pathname === '/api/threadgauge/sessions' && req.method === 'GET') {
541
+ return send(res, 200, await sessionsFromThreadgauge({ id: url.searchParams.get('id'), all: url.searchParams.has('all'), anotherDevice: isAnotherDevice(req) }))
497
542
  }
498
543
 
499
544
  if (url.pathname === '/api/pro' && req.method === 'GET') {
@@ -535,6 +580,7 @@ export async function middleware(req, res, next) {
535
580
 
536
581
  return send(res, 404, { error: 'Unknown endpoint' })
537
582
  } catch (err) {
583
+ if (isBusy(err)) return send(res, 503, { error: BUSY_MESSAGE })
538
584
  return send(res, 500, { error: String(err && err.message ? err.message : err) })
539
585
  }
540
586
  }
@@ -110,6 +110,14 @@ async function serveRequest(req, res) {
110
110
  }
111
111
  }
112
112
 
113
+ export function heldOnIpv6Loopback(port) {
114
+ return new Promise((resolve) => {
115
+ const trial = http.createServer()
116
+ trial.once('error', (err) => resolve(err.code === 'EADDRINUSE'))
117
+ trial.listen(port, '::1', () => trial.close(() => resolve(false)))
118
+ })
119
+ }
120
+
113
121
  export function startServer({ port, host }) {
114
122
  const server = http.createServer((req, res) => {
115
123
  serveRequest(req, res).catch(() => {
@@ -1,9 +1,17 @@
1
+ import { DETAIL_LIMITS, clip, clipBlock } from '@threadcrumbs/common/lib/telemetry-fields'
2
+
1
3
  const FIRST_PORT = 5274
2
4
  const PORTS_TO_TRY = 20
3
5
  const LOOPBACKS = ['127.0.0.1', '::1']
4
6
  const PROBE_TIMEOUT_MS = 500
5
- const CONTEXT_TIMEOUT_MS = 2000
7
+ const SESSIONS_TIMEOUT_MS = 2000
8
+ const HISTORY_TIMEOUT_MS = 8000
9
+ const REUSE_OLDER_MS = 15000
6
10
  const REUSE_FOUND_MS = 15000
11
+ const ASK_QUESTIONS = 4
12
+ const ASK_OPTIONS = 8
13
+ const LABEL_MAX = 160
14
+ const STATUS_MAX = 40
7
15
 
8
16
  const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
9
17
 
@@ -16,27 +24,103 @@ async function answersAsThreadgauge(origin, fetch) {
16
24
  }
17
25
  }
18
26
 
19
- export async function findThreadgauge({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
27
+ export async function locateThreadgauge({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
20
28
  const probes = []
21
29
  for (let port = first; port < first + tries; port++) for (const host of hosts) probes.push({ port, origin: originOf(host, port) })
22
30
  const found = await Promise.all(probes.map(({ origin }) => answersAsThreadgauge(origin, fetch)))
23
31
  const index = found.indexOf(true)
24
- return index === -1 ? null : `http://localhost:${probes[index].port}/`
32
+ return index === -1 ? null : { url: `http://localhost:${probes[index].port}/`, origin: probes[index].origin }
25
33
  }
26
34
 
27
- export function threadgaugeContexts({ fetch = globalThis.fetch, find = () => findThreadgauge({ fetch }), now = Date.now } = {}) {
28
- let found = { url: null, at: -Infinity }
29
- return async function contexts() {
30
- if (now() - found.at > REUSE_FOUND_MS) found = { url: await find(), at: now() }
31
- if (!found.url) return { url: null, contexts: {} }
35
+ export const findThreadgauge = async (options) => (await locateThreadgauge(options))?.url ?? null
36
+
37
+ const text = (max) => (v) => (typeof v === 'string' ? clip(v, max) : null)
38
+ const block = (max) => (v) => (typeof v === 'string' ? clipBlock(v, max) : null)
39
+ const label = text(LABEL_MAX)
40
+ const words = block(DETAIL_LIMITS.errorText)
41
+ const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
42
+ const list = (v, max, keep) => (Array.isArray(v) ? v.filter((x) => x && typeof x === 'object').slice(0, max).map(keep) : null)
43
+
44
+ function keepAsk(ask) {
45
+ if (!ask || typeof ask !== 'object') return null
46
+ if (ask.type === 'question') {
47
+ const option = (o) => ({ label: label(o.label) ?? '', description: words(o.description) ?? '' })
48
+ const question = (q) => ({ header: label(q.header) ?? '', text: words(q.text) ?? '', options: list(q.options, ASK_OPTIONS, option) ?? [], multiple: q.multiple === true })
49
+ return { type: 'question', questions: list(ask.questions, ASK_QUESTIONS, question) ?? [] }
50
+ }
51
+ if (ask.type === 'approval') return { type: 'approval', tool: label(ask.tool) ?? '', target: label(ask.target) ?? '', kind: label(ask.kind) ?? 'other' }
52
+ if (ask.type === 'error') return { type: 'error', text: words(ask.text) ?? '' }
53
+ return { type: 'unknown' }
54
+ }
55
+
56
+ const SESSION_FIELDS = {
57
+ status: text(STATUS_MAX),
58
+ statusReason: text(STATUS_MAX),
59
+ ask: keepAsk,
60
+ context: (c) => (c && typeof c === 'object' ? { used: number(c.used), limit: number(c.limit) } : null),
61
+ tool: (t) => (t && typeof t === 'object' ? { name: label(t.name) ?? '', startedAt: number(t.startedAt), target: label(t.target) ?? '', kind: label(t.kind) ?? 'other' } : null),
62
+ turnStartedAt: number,
63
+ workingSubagents: (subs) => list(subs, DETAIL_LIMITS.subagents, (s) => ({ id: label(s.id) ?? '', name: label(s.name) ?? '' })),
64
+ awaiting: (threads) => list(threads, DETAIL_LIMITS.subagents, (t) => ({ id: label(t.id) ?? '', title: label(t.title) ?? '' })),
65
+ subagents: number,
66
+ live: (v) => v === true,
67
+ plan: (p) => (p && typeof p === 'object' ? { done: number(p.done), total: number(p.total), current: label(p.current) ?? '' } : null),
68
+ tokens: (t) => (t && typeof t === 'object' ? { output: number(t.output) } : null),
69
+ lines: (l) => (l && typeof l === 'object' ? { added: number(l.added), removed: number(l.removed) } : null),
70
+ }
71
+
72
+ export function keepSessionFields(session) {
73
+ const kept = {}
74
+ for (const [field, keep] of Object.entries(SESSION_FIELDS)) if (field in session) kept[field] = keep(session[field])
75
+ return kept
76
+ }
77
+
78
+ function withoutText(ask) {
79
+ if (!ask) return ask
80
+ if (ask.type === 'question') return { type: 'question', questions: [] }
81
+ if (ask.type === 'approval') return { type: 'approval', tool: ask.tool, target: '', kind: ask.kind }
82
+ if (ask.type === 'error') return { type: 'error', text: '' }
83
+ return ask
84
+ }
85
+
86
+ export function keepForAnotherDevice(kept) {
87
+ const out = { ...kept }
88
+ if ('ask' in out) out.ask = withoutText(out.ask)
89
+ if (out.tool) out.tool = { ...out.tool, target: '' }
90
+ if (out.awaiting) out.awaiting = out.awaiting.map(({ id }) => ({ id, title: '' }))
91
+ if (out.plan) out.plan = { ...out.plan, current: '' }
92
+ return out
93
+ }
94
+
95
+ const listOf = async (response) => {
96
+ const { sessions = [] } = response.ok ? await response.json() : {}
97
+ return (Array.isArray(sessions) ? sessions : []).filter((s) => s && typeof s.id === 'string')
98
+ }
99
+
100
+ export function threadgaugeSessions({ fetch = globalThis.fetch, find = () => locateThreadgauge({ fetch }), now = Date.now } = {}) {
101
+ let found = { at: -Infinity, where: null }
102
+ let older = { at: -Infinity, origin: null, list: [] }
103
+ async function olderList(origin) {
104
+ if (older.origin === origin && now() - older.at <= REUSE_OLDER_MS) return older.list
105
+ const list = await listOf(await fetch(`${origin}/api/history`, { signal: AbortSignal.timeout(HISTORY_TIMEOUT_MS) }))
106
+ older = { at: now(), origin, list }
107
+ return list
108
+ }
109
+ return async function sessions({ id = null, all = false, anotherDevice = false } = {}) {
110
+ if (now() - found.at > REUSE_FOUND_MS) found = { at: now(), where: await find() }
111
+ const where = found.where
112
+ if (!where) return { url: null, sessions: {} }
113
+ if (!id && !all) return { url: where.url, sessions: {} }
32
114
  try {
33
- const response = await fetch(`${found.url}api/sessions`, { signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS) })
34
- const { sessions = [] } = response.ok ? await response.json() : {}
35
- const known = sessions.filter((s) => s.id && s.context).map((s) => [s.id, { used: s.context.used, limit: s.context.limit }])
36
- return { url: found.url, contexts: Object.fromEntries(known) }
115
+ const live = await listOf(await fetch(`${where.origin}/api/sessions`, { signal: AbortSignal.timeout(SESSIONS_TIMEOUT_MS) }))
116
+ const listed = new Set(live.map((s) => s.id))
117
+ const everything = all ? [...live, ...(await olderList(where.origin)).filter((s) => !listed.has(s.id))] : live
118
+ const wanted = everything.filter((s) => all || s.id === id)
119
+ const keep = (s) => (anotherDevice ? keepForAnotherDevice(keepSessionFields(s)) : keepSessionFields(s))
120
+ return { url: where.url, sessions: Object.fromEntries(wanted.map((s) => [s.id, keep(s)])) }
37
121
  } catch {
38
- found = { url: null, at: -Infinity }
39
- return { url: null, contexts: {} }
122
+ found = { at: -Infinity, where: null }
123
+ return { url: null, sessions: {} }
40
124
  }
41
125
  }
42
126
  }
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" class="world-icon" viewBox="0 0 100 100" aria-hidden="true"><defs><clipPath id="wi-frameg"><circle cx="50" cy="50" r="50"/></clipPath><linearGradient id="wi-l0" x1="0" y1="0" x2="0" y2="64" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#86a6c6"/><stop offset="0.58" stop-color="#c7c9c5"/><stop offset="1" stop-color="#f2e0c4"/></linearGradient><radialGradient id="wi-r1" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"><stop offset="0" stop-color="#f6e9d6" stop-opacity="0.3"/><stop offset="0.4" stop-color="#f6e9d6" stop-opacity="0.13"/><stop offset="1" stop-color="#f6e9d6" stop-opacity="0"/></radialGradient><radialGradient id="wi-r2" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"><stop offset="0" stop-color="#fff6e4" stop-opacity="0.6"/><stop offset="0.4" stop-color="#fff6e4" stop-opacity="0.25"/><stop offset="1" stop-color="#fff6e4" stop-opacity="0"/></radialGradient><clipPath id="wi-c3"><circle cx="31.1" cy="33.2" r="6.03"/></clipPath><radialGradient id="wi-r4" cx="0.66" cy="0.4" r="0.72" fx="0.66" fy="0.4"><stop offset="0" stop-color="#e3ddd3"/><stop offset="0.7" stop-color="#dcd4c8"/><stop offset="1" stop-color="#aca59c"/></radialGradient><linearGradient id="wi-l5" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.32"/><stop offset="0.5" stop-color="#000" stop-opacity="0.1"/><stop offset="0.78" stop-color="#000" stop-opacity="0"/></linearGradient><clipPath id="wi-c6"><circle cx="43.7" cy="28" r="4.92"/></clipPath><radialGradient id="wi-r7" cx="0.66" cy="0.4" r="0.72" fx="0.66" fy="0.4"><stop offset="0" stop-color="#d3c6b6"/><stop offset="0.7" stop-color="#c8b8a4"/><stop offset="1" stop-color="#9c9080"/></radialGradient><linearGradient id="wi-l8" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.32"/><stop offset="0.5" stop-color="#000" stop-opacity="0.1"/><stop offset="0.78" stop-color="#000" stop-opacity="0"/></linearGradient><radialGradient id="wi-r9" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"><stop offset="0" stop-color="#ffffff"/><stop offset="0.55" stop-color="#fffbf4"/><stop offset="1" stop-color="#fff6e4"/></radialGradient><linearGradient id="wi-la" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f2dcb5"/><stop offset="0.35" stop-color="#ebcf9e"/><stop offset="1" stop-color="#c7a06e"/></linearGradient><linearGradient id="wi-lb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3deb7"/><stop offset="0.35" stop-color="#eccf9c"/><stop offset="1" stop-color="#c29965"/></linearGradient><linearGradient id="wi-lc" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f5e1ba"/><stop offset="0.35" stop-color="#ecce9a"/><stop offset="1" stop-color="#ba8f59"/></linearGradient><linearGradient id="wi-ld" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6e3bc"/><stop offset="0.35" stop-color="#ecce98"/><stop offset="1" stop-color="#b4864e"/></linearGradient><radialGradient id="wi-re" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"><stop offset="0" stop-color="#ff6a2a" stop-opacity="0.9"/><stop offset="0.4" stop-color="#ff6a2a" stop-opacity="0.38"/><stop offset="1" stop-color="#ff6a2a" stop-opacity="0"/></radialGradient><linearGradient id="wi-lf" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ead0a8" stop-opacity="0"/><stop offset="0.55" stop-color="#ead0a8" stop-opacity="0.35"/><stop offset="1" stop-color="#ead0a8" stop-opacity="0"/></linearGradient><radialGradient id="wi-rh" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"><stop offset="0.62" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.3"/></radialGradient></defs><g clip-path="url(#wi-frameg)"><rect width="100" height="100" fill="url(#wi-l0)"/><ellipse cx="75" cy="62" rx="70" ry="14" fill="url(#wi-r1)"/><circle cx="75" cy="24" r="16.2" fill="url(#wi-r2)"/><circle cx="75" cy="24" r="4.5" fill="url(#wi-r9)"/><g opacity="0.6"><circle cx="31.1" cy="33.2" r="6.03" fill="url(#wi-r4)"/><g clip-path="url(#wi-c3)"><ellipse cx="30.94" cy="36.85" rx="0.49" ry="0.35" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(12.36 31.1 33.2)"/><ellipse cx="31.22" cy="29.7" rx="0.49" ry="0.24" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(14.59 31.1 33.2)"/><ellipse cx="28.65" cy="34.93" rx="0.94" ry="0.52" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(-5.2 31.1 33.2)"/><ellipse cx="30.21" cy="34.27" rx="0.95" ry="0.55" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(-17.63 31.1 33.2)"/><ellipse cx="27.06" cy="32.41" rx="1.39" ry="1.07" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(-0.55 31.1 33.2)"/><ellipse cx="30.82" cy="32.39" rx="1.07" ry="0.56" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(-29.66 31.1 33.2)"/><ellipse cx="34.3" cy="35.57" rx="1.2" ry="0.6" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(-11.43 31.1 33.2)"/><ellipse cx="28.24" cy="34.89" rx="1.03" ry="0.66" fill="#9a8e7e" fill-opacity="0.42" transform="rotate(10.85 31.1 33.2)"/><ellipse cx="29.59" cy="33.8" rx="1.93" ry="1.33" fill="#4e463e" fill-opacity=".7"/><circle cx="29.07" cy="31.74" r="0.49" fill="#aca59c"/><path d="M28.59 31.74A0.49 0.49 0 0 0 29.56 31.74" fill="none" stroke="#e7e1d9" stroke-width="0.17"/><circle cx="34.33" cy="33.54" r="0.89" fill="#aca59c"/><path d="M33.44 33.54A0.89 0.89 0 0 0 35.22 33.54" fill="none" stroke="#e7e1d9" stroke-width="0.31"/><circle cx="29.71" cy="34.16" r="0.88" fill="#aca59c"/><path d="M28.83 34.16A0.88 0.88 0 0 0 30.59 34.16" fill="none" stroke="#e7e1d9" stroke-width="0.31"/><rect x="25.07" y="27.17" width="12.07" height="12.07" fill="url(#wi-l5)"/></g><circle cx="31.1" cy="33.2" r="5.73" fill="none" stroke="#fff4e4" stroke-opacity=".35" stroke-width=".6"/></g><g opacity="0.6"><circle cx="43.7" cy="28" r="4.92" fill="url(#wi-r7)"/><g clip-path="url(#wi-c6)"><ellipse cx="43.4" cy="27.14" rx="0.98" ry="0.44" fill="#988874" fill-opacity="0.36" transform="rotate(-26.08 43.7 28)"/><ellipse cx="45.69" cy="26.96" rx="0.92" ry="0.73" fill="#988874" fill-opacity="0.36" transform="rotate(14.56 43.7 28)"/><ellipse cx="42.25" cy="26.6" rx="1.13" ry="0.64" fill="#988874" fill-opacity="0.36" transform="rotate(26.21 43.7 28)"/><ellipse cx="46.27" cy="30.8" rx="1.09" ry="0.51" fill="#988874" fill-opacity="0.36" transform="rotate(-7.29 43.7 28)"/><ellipse cx="42.87" cy="28.47" rx="0.69" ry="0.32" fill="#988874" fill-opacity="0.36" transform="rotate(-11.31 43.7 28)"/><ellipse cx="44.23" cy="25.53" rx="1.17" ry="0.83" fill="#988874" fill-opacity="0.36" transform="rotate(27.81 43.7 28)"/><ellipse cx="43.69" cy="25.53" rx="0.54" ry="0.41" fill="#988874" fill-opacity="0.36" transform="rotate(-2.78 43.7 28)"/><ellipse cx="46.72" cy="26.08" rx="1.14" ry="0.51" fill="#988874" fill-opacity="0.36" transform="rotate(-1.01 43.7 28)"/><circle cx="44.08" cy="26.22" r="0.54" fill="#9c9080"/><path d="M43.54 26.22A0.54 0.54 0 0 0 44.63 26.22" fill="none" stroke="#d9cdbf" stroke-width="0.19"/><circle cx="44.48" cy="30.7" r="0.75" fill="#9c9080"/><path d="M43.73 30.7A0.75 0.75 0 0 0 45.23 30.7" fill="none" stroke="#d9cdbf" stroke-width="0.26"/><circle cx="46.47" cy="29.29" r="0.71" fill="#9c9080"/><path d="M45.76 29.29A0.71 0.71 0 0 0 47.18 29.29" fill="none" stroke="#d9cdbf" stroke-width="0.25"/><rect x="38.78" y="23.08" width="9.85" height="9.85" fill="url(#wi-l8)"/></g><circle cx="43.7" cy="28" r="4.62" fill="none" stroke="#fff0dc" stroke-opacity=".35" stroke-width=".6"/></g><path d="M-3 64L-1.96 64L0.54 60.58L1.14 57.27L21.1 57.27L21.7 60.58L24.2 64L31.17 64L33.67 60.01L34.27 55.38L62.07 55.38L62.67 60.01L65.17 64L71.65 64L74.15 60.33L74.75 56.42L97.19 56.42L97.79 60.33L100.29 64L103 64Z" fill="#b9926b"/><path d="M0.84 58.84H21.4M0.84 60.42H21.4M33.97 57.59H62.37M33.97 59.79H62.37M74.45 58.28H97.49M74.45 60.14H97.49" fill="none" stroke="#987858" stroke-width=".5" stroke-opacity=".7"/><rect x="0" y="56" width="100" height="10" fill="url(#wi-lf)"/><path d="M-2 62H102V70H-2Z" fill="#cca777"/><path d="M-2 64.2C4 60 20 60.3 32 63.6S64 59.4 102 63.3V102H-2Z" fill="url(#wi-la)"/><path d="M-2 64.2C4 60 20 60.3 32 63.6S64 59.4 102 63.3" fill="none" stroke="#f9ebcd" stroke-opacity=".55" stroke-width=".7"/><path d="M-2 70.8C24 64.5 40 64.95 52 69.9S84 63.6 102 69.45V102H-2Z" fill="url(#wi-lb)"/><path d="M-2 70.8C24 64.5 40 64.95 52 69.9S84 63.6 102 69.45" fill="none" stroke="#f9ebcd" stroke-opacity=".55" stroke-width=".7"/><path d="M-2 79.4C4 71 20 71.6 32 78.2S64 69.8 102 77.6V102H-2Z" fill="url(#wi-lc)"/><path d="M-2 79.4C4 71 20 71.6 32 78.2S64 69.8 102 77.6" fill="none" stroke="#f9ebcd" stroke-opacity=".55" stroke-width=".7"/><path d="M-2 91.2C24 80 40 80.8 52 89.6S84 78.4 102 88.8V102H-2Z" fill="url(#wi-ld)"/><path d="M-2 91.2C24 80 40 80.8 52 89.6S84 78.4 102 88.8" fill="none" stroke="#f9ebcd" stroke-opacity=".55" stroke-width=".7"/><ellipse cx="33.35" cy="89.98" rx="5.53" ry="0.88" fill="#8e6a46" fill-opacity="0.61"/><ellipse cx="10.83" cy="72.12" rx="3.84" ry="0.61" fill="#8e6a46" fill-opacity="0.61"/><ellipse cx="50.09" cy="89.96" rx="6.76" ry="1.55" fill="#8e6a46" fill-opacity="0.61"/><ellipse cx="80.31" cy="87.95" rx="6.35" ry="1.5" fill="#8e6a46" fill-opacity="0.61"/><ellipse cx="83.2" cy="87.75" rx="5.87" ry="0.73" fill="#c4643a" fill-opacity="0.5"/><ellipse cx="11.21" cy="96.11" rx="6.95" ry="1.55" fill="#c4643a" fill-opacity="0.5"/><ellipse cx="12.87" cy="94.71" rx="5.66" ry="1.56" fill="#c4643a" fill-opacity="0.5"/><ellipse cx="87.26" cy="91.64" rx="4.63" ry="1.65" fill="#c4643a" fill-opacity="0.5"/><path d="M82.7 72L83.3 69L83.55 66.5L83.9 65L84.3 67L84.8 68.75L85.3 72Z" fill="#6c4c31"/><path d="M83.9 65L84.3 67L84.8 68.75L85.3 72L84.3 72Z" fill="#9a6c46"/><path d="M12.57 74L13.23 70.7L13.51 67.95L13.89 66.3L14.33 68.5L14.88 70.43L15.43 74Z" fill="#6c4c31"/><path d="M13.89 66.3L14.33 68.5L14.88 70.43L15.43 74L14.33 74Z" fill="#9a6c46"/><path d="M23.79 92L24.81 86.9L25.24 82.65L25.83 80.1L26.51 83.5L27.36 86.48L28.21 92Z" fill="#6c4c31"/><path d="M25.83 80.1L26.51 83.5L27.36 86.48L28.21 92L26.51 92Z" fill="#9a6c46"/><path d="M77.12 93L77.66 90.84L79.28 89.76L81.44 90.12L82.88 91.74L83.06 93Z" fill="#9a6c46"/><path d="M77.66 90.84L79.28 89.76L80.54 91L78.56 91.92Z" fill="#b8987e"/><circle cx="50.72" cy="73.64" r="1.6" fill="url(#wi-re)"/><circle cx="15.9" cy="75.19" r="1.6" fill="url(#wi-re)"/><circle cx="52.57" cy="83.81" r="1.6" fill="url(#wi-re)"/><circle cx="19.26" cy="79.5" r="1.6" fill="url(#wi-re)"/><circle cx="85.88" cy="74.14" r="1.6" fill="url(#wi-re)"/><circle cx="59.31" cy="90.22" r="1.6" fill="url(#wi-re)"/><path d="M68.72 24.47l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M17.44 42.28l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M79.77 80.74l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M7.88 95.69l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M11.31 43.26l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M50.55 14.52l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M45.9 84.46l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M50.7 84.42l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M52.72 9.31l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M37.63 45.33l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M15.45 82.88l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><path d="M86.3 33.96l-2 0.3" stroke="#f0d8a8" stroke-opacity=".7" stroke-width=".35" stroke-linecap="round"/><circle cx="91.1" cy="95.63" r="0.25" fill="#d8b090" fill-opacity=".85"/><circle cx="86.84" cy="59.61" r="0.34" fill="#d8b090" fill-opacity=".85"/><circle cx="63.36" cy="22.07" r="0.3" fill="#d8b090" fill-opacity=".85"/><rect width="100" height="100" fill="url(#wi-rh)"/></g><circle cx="50" cy="50" r="49.2" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="1.6"/></svg>