@threadcrumbs/threadnauts 1.2.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-C46d6g6p.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/shared-DiTtF988.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-PzTcyxAK.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-lBnPFibL.js",
5
- "sha256": "e8e7df01da6e616fb37a60ddc2657b63681cfd34adf8fb37116ecad906845a6e",
6
- "bytes": 233030
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.2.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",
@@ -53,7 +53,7 @@
53
53
  "auto-publish": "node ../common/tools/release.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@threadcrumbs/common": "^0.1.0"
56
+ "@threadcrumbs/common": "^0.1.7"
57
57
  },
58
58
  "devDependencies": {
59
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,
@@ -122,6 +124,7 @@ async function readState() {
122
124
  viewedAt: asObject(raw.viewedAt),
123
125
  trinkets: asObject(raw.trinkets),
124
126
  accents: asObject(raw.accents),
127
+ accentsShown: asObject(raw.accentsShown),
125
128
  districts: Number(raw.districts) || 0,
126
129
  settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
127
130
  updatedAt: Number(raw.updatedAt) || 0,
@@ -162,6 +165,7 @@ async function writeState(next) {
162
165
  viewedAt: asObject(next.viewedAt),
163
166
  trinkets: asObject(next.trinkets),
164
167
  accents: asObject(next.accents),
168
+ accentsShown: asObject(next.accentsShown),
165
169
  districts: Number(next.districts) || 0,
166
170
  settings: next.settings && typeof next.settings === 'object' ? next.settings : null,
167
171
  updatedAt: Date.now(),
@@ -421,10 +425,26 @@ function isLocalRequest(req) {
421
425
  if (!LOCAL_HOSTS.has(hostnameOf(req.headers.host))) return false
422
426
 
423
427
  const origin = req.headers.origin
424
- if (origin && origin !== 'null') return LOCAL_HOSTS.has(hostnameOf(origin))
428
+ if (origin && origin !== 'null') return isOwnOrigin(origin, req)
425
429
  return req.method === 'GET' || req.method === 'HEAD'
426
430
  }
427
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
+
428
448
  function readJsonBody(req, limit = 4 * 1024 * 1024) {
429
449
  return new Promise((resolve, reject) => {
430
450
  let size = 0
@@ -456,6 +476,9 @@ export async function middleware(req, res, next) {
456
476
  if (!isLocalRequest(req)) {
457
477
  return send(res, 403, { error: 'Threadnauts only answers its own page on this machine' })
458
478
  }
479
+ if (WRITE_METHODS.has(req.method) && !sendsJson(req)) {
480
+ return send(res, 415, { error: 'Threadnauts only accepts JSON' })
481
+ }
459
482
 
460
483
  try {
461
484
  if (url.pathname === '/api/threads' && req.method === 'GET') {
@@ -506,12 +529,16 @@ export async function middleware(req, res, next) {
506
529
  return send(res, 200, await aboutThisApp())
507
530
  }
508
531
 
532
+ if (url.pathname === '/api/accents' && req.method === 'GET') {
533
+ return send(res, 200, accentsAnswer(await readState()))
534
+ }
535
+
509
536
  if (url.pathname === '/api/threadgauge' && req.method === 'GET') {
510
537
  return send(res, 200, { url: await findThreadgauge() })
511
538
  }
512
539
 
513
- if (url.pathname === '/api/threadgauge/contexts' && req.method === 'GET') {
514
- 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) }))
515
542
  }
516
543
 
517
544
  if (url.pathname === '/api/pro' && req.method === 'GET') {
@@ -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>