@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/NOTICES.md +96 -0
- package/bin/threadnauts.mjs +13 -7
- package/dist/assets/arrakesh-B6KYoRjN.svg +1 -0
- package/dist/assets/index-BzSHs41z.css +1 -0
- package/dist/assets/index-CASWItKa.js +2509 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2 +0 -0
- package/dist/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/shared-CudSvZVV.js +5015 -0
- package/dist/index.html +3 -3
- package/dist/pro-manifest.json +3 -3
- package/package.json +2 -2
- package/server/accents.mjs +27 -0
- package/server/api.mjs +32 -5
- package/server/http-server.mjs +8 -0
- package/server/threadgauge.mjs +98 -14
- package/dist/assets/arrakesh-BW9Bb56U.svg +0 -1
- package/dist/assets/index-C46d6g6p.js +0 -2464
- package/dist/assets/index-PzTcyxAK.css +0 -1
- package/dist/assets/shared-DiTtF988.js +0 -4978
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-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/shared-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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">
|
package/dist/pro-manifest.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
3
|
{
|
|
4
|
-
"path": "assets/pro-
|
|
5
|
-
"sha256": "
|
|
6
|
-
"bytes":
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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/
|
|
514
|
-
return send(res, 200, await
|
|
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') {
|
package/server/http-server.mjs
CHANGED
|
@@ -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(() => {
|
package/server/threadgauge.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
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 = {
|
|
39
|
-
return { url: null,
|
|
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>
|