thebird 1.2.83 → 1.2.84
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/.github/workflows/publish.yml +8 -6
- package/docs/agent-chat.js +2 -2
- package/docs/app.js +20 -14
- package/docs/kilo-http-stream.js +54 -59
- package/package.json +1 -1
- package/start-kilo.js +23 -35
- package/docs/kilo-fs-mirror.js +0 -15
|
@@ -29,13 +29,15 @@ jobs:
|
|
|
29
29
|
- name: Bump patch version
|
|
30
30
|
run: |
|
|
31
31
|
git fetch --tags --force
|
|
32
|
-
|
|
32
|
+
HIGHEST=$(git tag -l 'v[0-9]*' | sort -V | tail -1 | sed 's/^v//')
|
|
33
|
+
CURRENT=$(node -p "require('./package.json').version")
|
|
34
|
+
BASE="$HIGHEST"
|
|
35
|
+
if [ -z "$BASE" ] || [ "$(printf '%s\n%s' "$CURRENT" "$BASE" | sort -V | tail -1)" = "$CURRENT" ]; then BASE="$CURRENT"; fi
|
|
36
|
+
node -e "const p=require('./package.json');p.version='$BASE';require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')"
|
|
37
|
+
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
|
38
|
+
while git rev-parse "$NEW_VERSION" >/dev/null 2>&1; do
|
|
39
|
+
echo "tag $NEW_VERSION exists, bumping again"
|
|
33
40
|
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
|
34
|
-
if git rev-parse "${NEW_VERSION}" >/dev/null 2>&1; then
|
|
35
|
-
echo "Tag ${NEW_VERSION} already exists, bumping again (attempt $i)"
|
|
36
|
-
continue
|
|
37
|
-
fi
|
|
38
|
-
break
|
|
39
41
|
done
|
|
40
42
|
git add package.json
|
|
41
43
|
git commit -m "chore: bump to ${NEW_VERSION}"
|
package/docs/agent-chat.js
CHANGED
|
@@ -91,8 +91,8 @@ function buildStream(provider) {
|
|
|
91
91
|
if (provider.type === 'gemini') {
|
|
92
92
|
return streamGemini({ model: provider.model, messages: provider.messages, tools: TOOLS, apiKey: provider.apiKey, maxOutputTokens: 8192 }).fullStream;
|
|
93
93
|
}
|
|
94
|
-
if (provider.type === 'kilo') {
|
|
95
|
-
return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages });
|
|
94
|
+
if (provider.type === 'kilo' || provider.type === 'opencode') {
|
|
95
|
+
return streamKiloHTTP({ url: provider.baseUrl, model: provider.model, messages: provider.messages, providerType: provider.type });
|
|
96
96
|
}
|
|
97
97
|
const url = (provider.baseUrl || '').replace(/\/$/, '') + '/chat/completions';
|
|
98
98
|
return streamOpenAI({ url, apiKey: provider.apiKey, messages: provider.messages, model: provider.model, tools: TOOLS, maxOutputTokens: 8192 });
|
package/docs/app.js
CHANGED
|
@@ -12,6 +12,7 @@ const PROVIDERS = {
|
|
|
12
12
|
deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', keyPlaceholder: 'DEEPSEEK_API_KEY', models: ['deepseek-chat', 'deepseek-reasoner'] },
|
|
13
13
|
cerebras: { label: 'Cerebras', baseUrl: 'https://api.cerebras.ai/v1', keyPlaceholder: 'CEREBRAS_API_KEY', models: ['gpt-oss-120b', 'llama3.1-8b'] },
|
|
14
14
|
kilo: { label: 'Kilo Code', baseUrl: 'http://localhost:4780', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'openrouter/free', 'kilo-auto/free'] },
|
|
15
|
+
opencode: { label: 'opencode', baseUrl: 'http://localhost:4790', keyPlaceholder: '(no key needed)', models: ['x-ai/grok-code-fast-1:optimized:free', 'openrouter/free', 'kilo-auto/free'] },
|
|
15
16
|
custom: { label: 'Custom (OpenAI-compat)', baseUrl: '', keyPlaceholder: 'API_KEY', models: [] },
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -95,6 +96,21 @@ class BirdChat extends HTMLElement {
|
|
|
95
96
|
this.setState({ providerType: type, baseUrl, model, models: [], apiKey: localStorage.getItem('provider_api_key') || '' });
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
renderBaseUrlInput() {
|
|
100
|
+
const { providerType, baseUrl } = this.state;
|
|
101
|
+
if (providerType !== 'custom' && providerType !== 'kilo' && providerType !== 'opencode') return null;
|
|
102
|
+
const ph = providerType === 'kilo' ? 'http://localhost:4780' : (providerType === 'opencode' ? 'http://localhost:4790' : 'https://your-endpoint/v1');
|
|
103
|
+
return html`<input type="text" class="tui-input" style="flex:1;min-width:140px" placeholder=${ph} value=${baseUrl}
|
|
104
|
+
onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />`;
|
|
105
|
+
}
|
|
106
|
+
renderApiKeyInput() {
|
|
107
|
+
const { providerType, apiKey } = this.state;
|
|
108
|
+
if (providerType === 'kilo' || providerType === 'opencode') return null;
|
|
109
|
+
const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
|
|
110
|
+
return html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px" placeholder=${provDef.keyPlaceholder} value=${apiKey}
|
|
111
|
+
onchange=${e => { const v = e.target.value.trim(); localStorage.setItem('provider_api_key', v); this.setState({ apiKey: v }); if (v) this.loadModels(); }} />`;
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
render() {
|
|
99
115
|
const { messages, streaming, model, apiKey, models, modelsLoading, status, providerType, baseUrl, streamingText } = this.state;
|
|
100
116
|
const provDef = PROVIDERS[providerType] || PROVIDERS.custom;
|
|
@@ -108,18 +124,8 @@ class BirdChat extends HTMLElement {
|
|
|
108
124
|
<div class="tui-toolbar">
|
|
109
125
|
<label>provider:</label>
|
|
110
126
|
<select class="tui-select" onchange=${e => this.setProvider(e.target.value)}>${provOpts}</select>
|
|
111
|
-
${(
|
|
112
|
-
|
|
113
|
-
placeholder=${providerType === 'kilo' ? 'http://localhost:4780' : 'https://your-endpoint/v1'} value=${baseUrl}
|
|
114
|
-
onchange=${e => { localStorage.setItem('provider_base_url', e.target.value); this.setState({ baseUrl: e.target.value }); }} />` : ''}
|
|
115
|
-
${providerType !== 'kilo' ? html`<input id="api-key-input" type="password" class="tui-input" style="flex:1;min-width:120px"
|
|
116
|
-
placeholder=${provDef.keyPlaceholder} value=${apiKey}
|
|
117
|
-
onchange=${e => {
|
|
118
|
-
const v = e.target.value.trim();
|
|
119
|
-
localStorage.setItem('provider_api_key', v);
|
|
120
|
-
this.setState({ apiKey: v });
|
|
121
|
-
if (v) this.loadModels();
|
|
122
|
-
}} />` : ''}
|
|
127
|
+
${this.renderBaseUrlInput()}
|
|
128
|
+
${this.renderApiKeyInput()}
|
|
123
129
|
${modelsLoading
|
|
124
130
|
? html`<span class="tui-spinner"></span>`
|
|
125
131
|
: html`<select class="tui-select" value=${model}
|
|
@@ -129,7 +135,7 @@ class BirdChat extends HTMLElement {
|
|
|
129
135
|
|
|
130
136
|
<div id="msg-list" class="tui-msglist">
|
|
131
137
|
${messages.map((m, i) => html`
|
|
132
|
-
<div key=${i} class=${'tui-msg ' + m.role}>${m.content}</div>`)}
|
|
138
|
+
<div key=${i} class=${'tui-msg ' + m.role}>${typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')}</div>`)}
|
|
133
139
|
${streaming && !streamingText && html`<div class="tui-msg assistant"><span class="tui-spinner"></span> thinking...</div>`}
|
|
134
140
|
</div>
|
|
135
141
|
|
|
@@ -152,7 +158,7 @@ class BirdChat extends HTMLElement {
|
|
|
152
158
|
const text = input?.value.trim();
|
|
153
159
|
if (!text || this.state.streaming) return;
|
|
154
160
|
const { apiKey, model, providerType, baseUrl } = this.state;
|
|
155
|
-
if (!apiKey && providerType !== 'kilo') { this.setState({ status: 'Enter an API key above.' }); return; }
|
|
161
|
+
if (!apiKey && providerType !== 'kilo' && providerType !== 'opencode') { this.setState({ status: 'Enter an API key above.' }); return; }
|
|
156
162
|
input.value = '';
|
|
157
163
|
input.style.height = 'auto';
|
|
158
164
|
const normalizedMessages = [...this.state.messages, { role: 'user', content: text }].map(m => ({
|
package/docs/kilo-http-stream.js
CHANGED
|
@@ -1,72 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export async function* streamKiloHTTP({ url, model, messages }) {
|
|
1
|
+
export async function* streamKiloHTTP({ url, model, messages, providerType }) {
|
|
4
2
|
yield { type: 'start-step' };
|
|
5
3
|
const base = (url || 'http://localhost:4780').replace(/\/$/, '');
|
|
6
|
-
const
|
|
4
|
+
const isOpencode = providerType === 'opencode';
|
|
5
|
+
const dbgKey = isOpencode ? 'opencode' : 'kilo';
|
|
7
6
|
let sessRes;
|
|
8
7
|
try { sessRes = await fetch(base + '/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
|
|
9
|
-
catch (e) { throw new Error('
|
|
10
|
-
if (!sessRes.ok) throw new Error('
|
|
8
|
+
catch (e) { throw new Error(dbgKey + ' serve not reachable at ' + base + ' — start it with: ' + dbgKey + ' serve --port ' + (new URL(base).port || (isOpencode ? 4790 : 4780)) + ' --cors ' + location.origin); }
|
|
9
|
+
if (!sessRes.ok) throw new Error('/session ' + sessRes.status + ': ' + await sessRes.text());
|
|
11
10
|
const { id: sessionId } = await sessRes.json();
|
|
12
|
-
Object.assign(window.__debug = window.__debug || {}, {
|
|
13
|
-
|
|
14
|
-
const queue = [];
|
|
15
|
-
let resolveNext = null;
|
|
16
|
-
let streamEnded = false;
|
|
17
|
-
const push = ev => { queue.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
|
|
18
|
-
const textSeen = new Set();
|
|
19
|
-
const toolState = new Map();
|
|
20
|
-
|
|
21
|
-
const es = new EventSource(base + '/event');
|
|
22
|
-
es.onmessage = (ev) => {
|
|
23
|
-
try {
|
|
24
|
-
const msg = JSON.parse(ev.data);
|
|
25
|
-
if (msg.type !== 'message.part.updated') return;
|
|
26
|
-
const part = msg.properties?.part;
|
|
27
|
-
if (!part) return;
|
|
28
|
-
if (part.type === 'text' && part.messageID && !textSeen.has(part.id)) {
|
|
29
|
-
const prior = toolState.get('__text_' + part.id) || '';
|
|
30
|
-
const txt = part.text || '';
|
|
31
|
-
if (txt.length > prior.length) { push({ type: 'text-delta', textDelta: txt.slice(prior.length) }); toolState.set('__text_' + part.id, txt); }
|
|
32
|
-
} else if (part.type === 'tool') {
|
|
33
|
-
const cid = part.callID;
|
|
34
|
-
const st = part.state?.status;
|
|
35
|
-
if (st === 'running' && !toolState.has(cid)) {
|
|
36
|
-
toolState.set(cid, { name: part.tool, args: part.state.input || {} });
|
|
37
|
-
push({ type: 'tool-call', toolCallId: cid, toolName: part.tool, args: part.state.input || {} });
|
|
38
|
-
window.__debug.kilo.toolCalls.push({ id: cid, name: part.tool, args: part.state.input || {} });
|
|
39
|
-
} else if (st === 'completed' && toolState.has(cid) && !toolState.get(cid).completed) {
|
|
40
|
-
toolState.get(cid).completed = true;
|
|
41
|
-
push({ type: 'tool-result', toolCallId: cid, toolName: part.tool, args: part.state.input || {}, result: part.state.output || '' });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
} catch (_) {}
|
|
45
|
-
};
|
|
11
|
+
Object.assign(window.__debug = window.__debug || {}, { [dbgKey]: { sessionId, url: base, lastStatus: null } });
|
|
46
12
|
|
|
47
13
|
const userText = messages.filter(m => m.role === 'user').map(m =>
|
|
48
14
|
typeof m.content === 'string' ? m.content : (m.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
|
|
49
15
|
).join('\n');
|
|
50
16
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
window.__debug.kilo.lastResult = json;
|
|
56
|
-
streamEnded = true;
|
|
57
|
-
if (resolveNext) { const x = resolveNext; resolveNext = null; x(); }
|
|
58
|
-
return json;
|
|
59
|
-
});
|
|
17
|
+
const modelId = model || 'x-ai/grok-code-fast-1:optimized:free';
|
|
18
|
+
const body = { parts: [{ type: 'text', text: userText }], agent: 'hermes-llm' };
|
|
19
|
+
if (isOpencode) body.model = { providerID: 'kilo', modelID: modelId };
|
|
20
|
+
else { body.providerID = 'kilo'; body.modelID = modelId; }
|
|
60
21
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
22
|
+
let text = '';
|
|
23
|
+
if (isOpencode) {
|
|
24
|
+
const es = new EventSource(base + '/event');
|
|
25
|
+
const textByPart = new Map();
|
|
26
|
+
let done = false;
|
|
27
|
+
const pending = [];
|
|
28
|
+
let resolveNext = null;
|
|
29
|
+
const push = ev => { pending.push(ev); if (resolveNext) { const r = resolveNext; resolveNext = null; r(); } };
|
|
30
|
+
es.onmessage = e => {
|
|
31
|
+
try {
|
|
32
|
+
const m = JSON.parse(e.data);
|
|
33
|
+
if (m.type === 'message.part.updated') {
|
|
34
|
+
const part = m.properties?.part;
|
|
35
|
+
if (part?.sessionID === sessionId && part.type === 'text') {
|
|
36
|
+
const prior = textByPart.get(part.id) || '';
|
|
37
|
+
const txt = part.text || '';
|
|
38
|
+
if (txt.length > prior.length) { push({ type:'text-delta', textDelta: txt.slice(prior.length) }); textByPart.set(part.id, txt); }
|
|
39
|
+
}
|
|
40
|
+
} else if (m.type === 'message.updated') {
|
|
41
|
+
const info = m.properties?.info;
|
|
42
|
+
if (info?.sessionID === sessionId && info.role === 'assistant' && info.time?.completed) {
|
|
43
|
+
done = true; if (resolveNext) { const r = resolveNext; resolveNext = null; r(); }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (_) {}
|
|
47
|
+
};
|
|
48
|
+
const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
49
|
+
window.__debug[dbgKey].lastStatus = msgRes.status;
|
|
50
|
+
if (!msgRes.ok) { es.close(); throw new Error('message ' + msgRes.status + ': ' + await msgRes.text()); }
|
|
51
|
+
const deadline = Date.now() + 180000;
|
|
52
|
+
while (!done || pending.length) {
|
|
53
|
+
if (pending.length) { const ev = pending.shift(); if (ev.type === 'text-delta') text += ev.textDelta; yield ev; continue; }
|
|
54
|
+
if (Date.now() > deadline) break;
|
|
55
|
+
await new Promise(r => { resolveNext = r; setTimeout(r, 5000); });
|
|
56
|
+
}
|
|
57
|
+
es.close();
|
|
58
|
+
} else {
|
|
59
|
+
const msgRes = await fetch(base + '/session/' + sessionId + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
60
|
+
window.__debug[dbgKey].lastStatus = msgRes.status;
|
|
61
|
+
if (!msgRes.ok) throw new Error('message ' + msgRes.status + ': ' + await msgRes.text());
|
|
62
|
+
const result = await msgRes.json();
|
|
63
|
+
window.__debug[dbgKey].lastResult = result;
|
|
64
|
+
for (const tp of (result.parts || []).filter(p => p.type === 'text')) { text += tp.text; yield { type: 'text-delta', textDelta: tp.text }; }
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
es.close();
|
|
67
|
-
const touched = [...toolState.values()].filter(v => v.completed && (v.name === 'write' || v.name === 'edit')).map(v => v.args.filePath).filter(Boolean);
|
|
68
|
-
const mirrored = await mirrorFromSandbox(fsBase, touched);
|
|
69
|
-
window.__debug.kilo.writes = mirrored;
|
|
70
|
-
if (mirrored.length) window.refreshPreview?.();
|
|
71
|
-
yield { type: 'finish-step', finishReason: result.info?.finish || 'stop' };
|
|
66
|
+
yield { type: 'finish-step', finishReason: 'stop' };
|
|
72
67
|
}
|
package/package.json
CHANGED
package/start-kilo.js
CHANGED
|
@@ -1,45 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { spawn } = require('child_process');
|
|
3
|
-
const http = require('http');
|
|
4
3
|
const fs = require('fs');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const os = require('os');
|
|
7
6
|
const args = process.argv.slice(2);
|
|
8
7
|
const get = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
|
|
9
|
-
const kiloPort = get('--port', '4780');
|
|
10
|
-
const
|
|
8
|
+
const kiloPort = get('--kilo-port', '4780');
|
|
9
|
+
const ocPort = get('--opencode-port', '4790');
|
|
11
10
|
const origin = get('--origin', 'http://localhost:8787');
|
|
12
11
|
const sandbox = path.resolve(get('--sandbox', '.sandbox'));
|
|
13
12
|
fs.mkdirSync(sandbox, { recursive: true });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!full.startsWith(sandbox)) { res.writeHead(403, cors); res.end('forbidden'); return; }
|
|
36
|
-
fs.readFile(full, (err, data) => {
|
|
37
|
-
if (err) { res.writeHead(404, cors); res.end('not found'); return; }
|
|
38
|
-
const ct = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.md': 'text/plain' }[path.extname(rel)] || 'application/octet-stream';
|
|
39
|
-
res.writeHead(200, { ...cors, 'Content-Type': ct });
|
|
40
|
-
res.end(data);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
srv.listen(fsPort, '127.0.0.1', () => console.log('[fs-bridge] sandbox=' + sandbox + ' serving http://127.0.0.1:' + fsPort));
|
|
44
|
-
kilo.on('exit', c => { srv.close(); process.exit(c || 0); });
|
|
45
|
-
process.on('SIGINT', () => { kilo.kill(); srv.close(); });
|
|
13
|
+
try { fs.writeFileSync(path.join(sandbox, '.gitignore'), '*\n!.gitignore\n'); } catch (e) {}
|
|
14
|
+
|
|
15
|
+
const isWin = os.platform() === 'win32';
|
|
16
|
+
const kiloBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\@kilocode\\cli\\node_modules\\@kilocode\\cli-windows-x64\\bin\\kilo.exe' : 'kilo';
|
|
17
|
+
const ocBin = isWin ? process.env.USERPROFILE + '\\AppData\\Roaming\\npm\\node_modules\\opencode-windows-x64\\bin\\opencode.exe' : 'opencode';
|
|
18
|
+
|
|
19
|
+
const procs = [];
|
|
20
|
+
const launch = (name, bin, port) => {
|
|
21
|
+
if (!args.includes('--no-' + name) && fs.existsSync(bin)) {
|
|
22
|
+
const p = spawn(bin, ['serve', '--port', port, '--hostname', '127.0.0.1', '--cors', origin], { stdio: 'inherit', env: process.env, cwd: sandbox });
|
|
23
|
+
procs.push(p);
|
|
24
|
+
console.log(`[${name}] serve --port ${port} pid ${p.pid}`);
|
|
25
|
+
} else if (!fs.existsSync(bin)) console.log(`[${name}] skip (${bin} not found)`);
|
|
26
|
+
};
|
|
27
|
+
launch('kilo', kiloBin, kiloPort);
|
|
28
|
+
launch('opencode', ocBin, ocPort);
|
|
29
|
+
|
|
30
|
+
const stop = () => { for (const p of procs) { try { p.kill(); } catch (e) {} } process.exit(0); };
|
|
31
|
+
process.on('SIGINT', stop);
|
|
32
|
+
process.on('SIGTERM', stop);
|
|
33
|
+
Promise.all(procs.map(p => new Promise(r => p.on('exit', r)))).then(() => process.exit(0));
|
package/docs/kilo-fs-mirror.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export async function mirrorFromSandbox(fsBase, _touchedPaths) {
|
|
2
|
-
const listRes = await fetch(fsBase + '/__list');
|
|
3
|
-
if (!listRes.ok) return [];
|
|
4
|
-
const relFiles = await listRes.json();
|
|
5
|
-
const snap = window.__debug.idbSnapshot || (window.__debug.idbSnapshot = {});
|
|
6
|
-
const mirrored = [];
|
|
7
|
-
for (const rel of relFiles) {
|
|
8
|
-
const r = await fetch(fsBase + '/' + rel);
|
|
9
|
-
if (!r.ok) continue;
|
|
10
|
-
const content = await r.text();
|
|
11
|
-
if (snap[rel] !== content) { snap[rel] = content; mirrored.push(rel); }
|
|
12
|
-
}
|
|
13
|
-
if (mirrored.length) { window.__debug.idbPersist?.(); window.__debug.shell?.onPreviewWrite?.(); }
|
|
14
|
-
return mirrored;
|
|
15
|
-
}
|