talon-agent 1.34.0 → 1.35.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/package.json +3 -3
- package/src/bootstrap.ts +5 -1
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/mesh.ts +22 -0
- package/src/core/engine/gateway.ts +7 -6
- package/src/core/mesh/index.ts +27 -0
- package/src/core/mesh/registry.ts +272 -0
- package/src/core/mesh/service.ts +550 -0
- package/src/core/mesh/types.ts +137 -0
- package/src/core/tools/index.ts +31 -0
- package/src/core/tools/mesh.ts +71 -0
- package/src/core/tools/messaging.ts +5 -0
- package/src/core/tools/types.ts +13 -1
- package/src/core/weaver/index.ts +9 -2
- package/src/core/weaver/loom.ts +33 -2
- package/src/core/weaver/memory-prefetch.ts +49 -0
- package/src/core/weaver/shuttle.ts +63 -0
- package/src/core/weaver/typing-loop.ts +36 -0
- package/src/core/weaver/warp-resolver.ts +123 -0
- package/src/core/weaver/weaver.ts +118 -209
- package/src/frontend/native/actions.ts +17 -1
- package/src/frontend/native/index.ts +48 -1
- package/src/frontend/native/protocol.ts +35 -0
- package/src/frontend/native/server.ts +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
101
101
|
"@kilocode/sdk": "^7.2.22",
|
|
102
102
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
103
|
-
"@openai/agents": "^0.
|
|
104
|
-
"@openai/codex-sdk": "^0.
|
|
103
|
+
"@openai/agents": "^0.13.0",
|
|
104
|
+
"@openai/codex-sdk": "^0.143.0",
|
|
105
105
|
"@opencode-ai/sdk": "^1.17.4",
|
|
106
106
|
"@playwright/mcp": "^0.0.77",
|
|
107
107
|
"@types/cross-spawn": "^6.0.6",
|
package/src/bootstrap.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
initTriggers,
|
|
29
29
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
30
30
|
} from "./core/background/triggers/index.js";
|
|
31
|
-
import { initDream } from "./core/background/dream.js";
|
|
31
|
+
import { initDream, maybeStartDream } from "./core/background/dream.js";
|
|
32
32
|
import { initHeartbeat } from "./core/background/heartbeat/index.js";
|
|
33
33
|
import { log, logWarn, logDebug } from "./util/log.js";
|
|
34
34
|
import type { TalonConfig } from "./util/config.js";
|
|
@@ -409,6 +409,10 @@ export async function initBackendAndDispatcher(
|
|
|
409
409
|
chatId,
|
|
410
410
|
),
|
|
411
411
|
onActivity: () => resetPulseTimer(),
|
|
412
|
+
// Turn-start hook: fire-and-forget dream (background memory
|
|
413
|
+
// consolidation) check. Wired here so the Weaver stays ignorant of
|
|
414
|
+
// the dream subsystem.
|
|
415
|
+
onTurnStart: maybeStartDream,
|
|
412
416
|
});
|
|
413
417
|
|
|
414
418
|
initPulse();
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - `skills` — markdown workflows
|
|
17
17
|
* - `plugins` — plugin hot-reload
|
|
18
18
|
* - `models` — model / backend discovery
|
|
19
|
+
* - `mesh` — companion device mesh (presence + location)
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { ActionResult } from "../../types.js";
|
|
@@ -30,6 +31,7 @@ import { scriptHandlers } from "./scripts.js";
|
|
|
30
31
|
import { skillHandlers } from "./skills.js";
|
|
31
32
|
import { pluginHandlers } from "./plugins.js";
|
|
32
33
|
import { modelHandlers } from "./models.js";
|
|
34
|
+
import { meshHandlers } from "./mesh.js";
|
|
33
35
|
|
|
34
36
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
35
37
|
// can't resolve an inherited Object.prototype method — `handlers[action]` only
|
|
@@ -44,6 +46,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
44
46
|
...skillHandlers,
|
|
45
47
|
...pluginHandlers,
|
|
46
48
|
...modelHandlers,
|
|
49
|
+
...meshHandlers,
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
export async function handleSharedAction(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device mesh — read tools (list_devices, get_device_location,
|
|
3
|
+
* get_device_history) and command tools (ring, live status).
|
|
4
|
+
*
|
|
5
|
+
* Shared actions, so the model has full mesh access from every frontend
|
|
6
|
+
* (Telegram, Discord, Teams, terminal, native) — not just chats running
|
|
7
|
+
* through the native bridge. The mesh itself is daemon-wide state
|
|
8
|
+
* (core/mesh); the native bridge is merely the transport companions
|
|
9
|
+
* register through and commands travel over.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getMeshService } from "../../mesh/index.js";
|
|
13
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export const meshHandlers: SharedActionHandlers = {
|
|
16
|
+
list_devices: () => getMeshService().describeDevices(),
|
|
17
|
+
get_device_location: (body) => getMeshService().locateDevice(body.device),
|
|
18
|
+
get_device_history: (body) =>
|
|
19
|
+
getMeshService().deviceHistory(body.device, body.hours),
|
|
20
|
+
ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
|
|
21
|
+
get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
|
|
22
|
+
};
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import pRetry, { AbortError } from "p-retry";
|
|
17
17
|
import { classify } from "../errors.js";
|
|
18
18
|
import { getActiveCount } from "./dispatcher.js";
|
|
19
|
-
import { Loom, getActiveLoom } from "../weaver/index.js";
|
|
19
|
+
import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
|
|
20
20
|
import { getHealthStatus } from "../../util/watchdog.js";
|
|
21
21
|
import { getActiveSessionCount } from "../../storage/sessions.js";
|
|
22
22
|
import { log, logError, logDebug } from "../../util/log.js";
|
|
@@ -89,13 +89,14 @@ export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
89
89
|
export class Gateway {
|
|
90
90
|
/**
|
|
91
91
|
* Per-chat live state is owned by the Weaver's Loom. The gateway holds no
|
|
92
|
-
* registry of its own — it delegates here
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* registry of its own — it delegates here, and only through the
|
|
93
|
+
* `ContextRegistry` face (the gateway never creates or evicts Threads).
|
|
94
|
+
* `getActiveLoom()` returns the Weaver's Loom once the dispatcher is wired;
|
|
95
|
+
* the standalone fallback covers unit tests and the brief startup window
|
|
96
|
+
* before init (when no turn — and so no context — can exist anyway).
|
|
96
97
|
*/
|
|
97
98
|
private readonly ownLoom = new Loom();
|
|
98
|
-
private get loom():
|
|
99
|
+
private get loom(): ContextRegistry {
|
|
99
100
|
return getActiveLoom() ?? this.ownLoom;
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device mesh — core module barrel.
|
|
3
|
+
*
|
|
4
|
+
* Registry (persistence) + service (policy, locate fan-out, tool surface) +
|
|
5
|
+
* canonical device/location types. Transports and gateway actions import
|
|
6
|
+
* from here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { MeshRegistry } from "./registry.js";
|
|
10
|
+
export {
|
|
11
|
+
MeshService,
|
|
12
|
+
getMeshService,
|
|
13
|
+
setMeshService,
|
|
14
|
+
type MeshTransport,
|
|
15
|
+
type MeshServiceOptions,
|
|
16
|
+
type MeshToolResult,
|
|
17
|
+
} from "./service.js";
|
|
18
|
+
export {
|
|
19
|
+
sanitizeCapabilities,
|
|
20
|
+
toDeviceInfo,
|
|
21
|
+
toDeviceLocation,
|
|
22
|
+
type DeviceCommand,
|
|
23
|
+
type DeviceCommandResult,
|
|
24
|
+
type DeviceInfo,
|
|
25
|
+
type DeviceLocation,
|
|
26
|
+
type DevicePlatform,
|
|
27
|
+
} from "./types.js";
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshRegistry — persistent storage for companion devices, last-known
|
|
3
|
+
* locations, and a bounded per-device location/battery history.
|
|
4
|
+
*
|
|
5
|
+
* Pure state: validate, upsert, persist (0600 JSON sidecars under the Talon
|
|
6
|
+
* root). No transport and no waiting logic — that policy lives in
|
|
7
|
+
* MeshService, so the registry stays trivially testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { dirs } from "../../util/paths.js";
|
|
13
|
+
import {
|
|
14
|
+
sanitizeCapabilities,
|
|
15
|
+
toDeviceInfo,
|
|
16
|
+
toDeviceLocation,
|
|
17
|
+
type DeviceInfo,
|
|
18
|
+
type DeviceLocation,
|
|
19
|
+
type DevicePlatform,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
const PRESENCE_TIMEOUT_MS = 90_000;
|
|
23
|
+
const DEVICE_FILE = resolve(dirs.root, "mesh-devices.json");
|
|
24
|
+
const LOCATION_FILE = resolve(dirs.root, "mesh-locations.json");
|
|
25
|
+
const HISTORY_FILE = resolve(dirs.root, "mesh-history.json");
|
|
26
|
+
/** Newest fixes kept per device (~a day of 5-minute periodic reports). */
|
|
27
|
+
const MAX_HISTORY_PER_DEVICE = 500;
|
|
28
|
+
const PLATFORMS = new Set<DevicePlatform>([
|
|
29
|
+
"android",
|
|
30
|
+
"macos",
|
|
31
|
+
"windows",
|
|
32
|
+
"linux",
|
|
33
|
+
"ios",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export class MeshRegistry {
|
|
37
|
+
private devices = new Map<string, DeviceInfo>();
|
|
38
|
+
private locations = new Map<string, DeviceLocation>();
|
|
39
|
+
private history = new Map<string, DeviceLocation[]>();
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly files = {
|
|
43
|
+
devices: DEVICE_FILE,
|
|
44
|
+
locations: LOCATION_FILE,
|
|
45
|
+
history: HISTORY_FILE,
|
|
46
|
+
},
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async load(): Promise<void> {
|
|
50
|
+
this.devices = new Map(
|
|
51
|
+
(await readArray<DeviceInfo>(this.files.devices))
|
|
52
|
+
.map((d) => sanitizeDevice(d, d.lastSeen, false))
|
|
53
|
+
.filter((d): d is DeviceInfo => d !== null)
|
|
54
|
+
.map((d) => [d.id, d]),
|
|
55
|
+
);
|
|
56
|
+
this.locations = new Map(
|
|
57
|
+
(await readArray<DeviceLocation>(this.files.locations))
|
|
58
|
+
.map(sanitizeLocation)
|
|
59
|
+
.filter((l): l is DeviceLocation => l !== null)
|
|
60
|
+
.map((l) => [l.deviceId, l]),
|
|
61
|
+
);
|
|
62
|
+
this.history = new Map();
|
|
63
|
+
for (const fix of (await readArray<DeviceLocation>(this.files.history))
|
|
64
|
+
.map(sanitizeLocation)
|
|
65
|
+
.filter((l): l is DeviceLocation => l !== null)) {
|
|
66
|
+
this.appendHistory(fix);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async register(
|
|
71
|
+
body: Record<string, unknown>,
|
|
72
|
+
now = Date.now(),
|
|
73
|
+
): Promise<DeviceInfo> {
|
|
74
|
+
const next = sanitizeDevice(body, now, true);
|
|
75
|
+
if (!next) throw new Error("Invalid device registration");
|
|
76
|
+
const prev = this.devices.get(next.id);
|
|
77
|
+
const device = toDeviceInfo(
|
|
78
|
+
{
|
|
79
|
+
...prev,
|
|
80
|
+
...next,
|
|
81
|
+
online: true,
|
|
82
|
+
lastSeen: now,
|
|
83
|
+
},
|
|
84
|
+
now,
|
|
85
|
+
);
|
|
86
|
+
this.devices.set(device.id, device);
|
|
87
|
+
await this.persistDevices();
|
|
88
|
+
return device;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async storeLocation(
|
|
92
|
+
body: Record<string, unknown>,
|
|
93
|
+
now = Date.now(),
|
|
94
|
+
): Promise<DeviceLocation> {
|
|
95
|
+
const loc = sanitizeLocation(body);
|
|
96
|
+
if (!loc) throw new Error("Invalid device location");
|
|
97
|
+
this.locations.set(loc.deviceId, loc);
|
|
98
|
+
const device = this.devices.get(loc.deviceId);
|
|
99
|
+
if (device) {
|
|
100
|
+
this.devices.set(
|
|
101
|
+
loc.deviceId,
|
|
102
|
+
toDeviceInfo(
|
|
103
|
+
{
|
|
104
|
+
...device,
|
|
105
|
+
online: true,
|
|
106
|
+
lastSeen: now,
|
|
107
|
+
...(typeof loc.batteryPct === "number"
|
|
108
|
+
? { battery: loc.batteryPct }
|
|
109
|
+
: {}),
|
|
110
|
+
},
|
|
111
|
+
now,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
await this.persistDevices();
|
|
115
|
+
}
|
|
116
|
+
this.appendHistory(loc);
|
|
117
|
+
await this.persistLocations();
|
|
118
|
+
await this.persistHistory();
|
|
119
|
+
return loc;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Fixes for one device since `sinceTs`, oldest first. */
|
|
123
|
+
getHistory(deviceId: string, sinceTs = 0): DeviceLocation[] {
|
|
124
|
+
return (this.history.get(deviceId) ?? [])
|
|
125
|
+
.filter((l) => l.ts >= sinceTs)
|
|
126
|
+
.map(toDeviceLocation);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Append one fix to the device's rolling history (dedup on timestamp,
|
|
130
|
+
* chronological order, bounded to MAX_HISTORY_PER_DEVICE). */
|
|
131
|
+
private appendHistory(fix: DeviceLocation): void {
|
|
132
|
+
const list = this.history.get(fix.deviceId) ?? [];
|
|
133
|
+
if (list.some((l) => l.ts === fix.ts)) return;
|
|
134
|
+
list.push(toDeviceLocation(fix));
|
|
135
|
+
list.sort((a, b) => a.ts - b.ts);
|
|
136
|
+
if (list.length > MAX_HISTORY_PER_DEVICE) {
|
|
137
|
+
list.splice(0, list.length - MAX_HISTORY_PER_DEVICE);
|
|
138
|
+
}
|
|
139
|
+
this.history.set(fix.deviceId, list);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list(now = Date.now()): {
|
|
143
|
+
devices: DeviceInfo[];
|
|
144
|
+
locations: DeviceLocation[];
|
|
145
|
+
} {
|
|
146
|
+
return {
|
|
147
|
+
devices: [...this.devices.values()]
|
|
148
|
+
.map((d) => toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS))
|
|
149
|
+
.sort((a, b) => b.lastSeen - a.lastSeen),
|
|
150
|
+
locations: [...this.locations.values()]
|
|
151
|
+
.map(toDeviceLocation)
|
|
152
|
+
.sort((a, b) => b.ts - a.ts),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getLocation(deviceId: string): DeviceLocation | undefined {
|
|
157
|
+
const loc = this.locations.get(deviceId);
|
|
158
|
+
return loc ? toDeviceLocation(loc) : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async persistDevices(): Promise<void> {
|
|
162
|
+
await writePrivateJson(this.files.devices, [...this.devices.values()]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async persistLocations(): Promise<void> {
|
|
166
|
+
await writePrivateJson(this.files.locations, [...this.locations.values()]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async persistHistory(): Promise<void> {
|
|
170
|
+
await writePrivateJson(
|
|
171
|
+
this.files.history,
|
|
172
|
+
[...this.history.values()].flat(),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readArray<T>(path: string): Promise<T[]> {
|
|
178
|
+
try {
|
|
179
|
+
const raw = await readFile(path, "utf8");
|
|
180
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
181
|
+
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writePrivateJson(path: string, value: unknown): Promise<void> {
|
|
188
|
+
await mkdir(dirname(path), { recursive: true });
|
|
189
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeDevice(
|
|
193
|
+
body: Record<string, unknown>,
|
|
194
|
+
now: number,
|
|
195
|
+
requireCore: boolean,
|
|
196
|
+
): DeviceInfo | null {
|
|
197
|
+
const id = stringField(body.id);
|
|
198
|
+
const name = stringField(body.name);
|
|
199
|
+
const appVersion = stringField(body.appVersion);
|
|
200
|
+
const platform = stringField(body.platform) as DevicePlatform | undefined;
|
|
201
|
+
if ((requireCore || id !== undefined) && !id) return null;
|
|
202
|
+
if ((requireCore || name !== undefined) && !name) return null;
|
|
203
|
+
if ((requireCore || appVersion !== undefined) && !appVersion) return null;
|
|
204
|
+
if (
|
|
205
|
+
(requireCore || platform !== undefined) &&
|
|
206
|
+
(!platform || !PLATFORMS.has(platform))
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
id: id ?? "",
|
|
212
|
+
name: name ?? "",
|
|
213
|
+
platform: platform ?? "linux",
|
|
214
|
+
appVersion: appVersion ?? "",
|
|
215
|
+
online: body.online === true,
|
|
216
|
+
lastSeen: numberField(body.lastSeen) ?? now,
|
|
217
|
+
...(numberField(body.battery) !== undefined
|
|
218
|
+
? { battery: numberField(body.battery) }
|
|
219
|
+
: {}),
|
|
220
|
+
...(typeof body.charging === "boolean" ? { charging: body.charging } : {}),
|
|
221
|
+
...(sanitizeCapabilities(body.capabilities)
|
|
222
|
+
? { capabilities: sanitizeCapabilities(body.capabilities) }
|
|
223
|
+
: {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sanitizeLocation(
|
|
228
|
+
body: Record<string, unknown>,
|
|
229
|
+
): DeviceLocation | null {
|
|
230
|
+
const deviceId = stringField(body.deviceId);
|
|
231
|
+
const lat = numberField(body.lat);
|
|
232
|
+
const lon = numberField(body.lon);
|
|
233
|
+
const ts = numberField(body.ts);
|
|
234
|
+
if (!deviceId || !Number.isFinite(lat) || !Number.isFinite(lon) || !ts) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (lat! < -90 || lat! > 90 || lon! < -180 || lon! > 180) return null;
|
|
238
|
+
return toDeviceLocation({
|
|
239
|
+
deviceId,
|
|
240
|
+
lat: lat!,
|
|
241
|
+
lon: lon!,
|
|
242
|
+
...(numberField(body.accuracyM) !== undefined
|
|
243
|
+
? { accuracyM: numberField(body.accuracyM) }
|
|
244
|
+
: {}),
|
|
245
|
+
...(numberField(body.altitudeM) !== undefined
|
|
246
|
+
? { altitudeM: numberField(body.altitudeM) }
|
|
247
|
+
: {}),
|
|
248
|
+
...(numberField(body.speedMps) !== undefined
|
|
249
|
+
? { speedMps: numberField(body.speedMps) }
|
|
250
|
+
: {}),
|
|
251
|
+
...(numberField(body.headingDeg) !== undefined
|
|
252
|
+
? { headingDeg: numberField(body.headingDeg) }
|
|
253
|
+
: {}),
|
|
254
|
+
ts,
|
|
255
|
+
...(stringField(body.provider)
|
|
256
|
+
? { provider: stringField(body.provider) }
|
|
257
|
+
: {}),
|
|
258
|
+
...(numberField(body.batteryPct) !== undefined
|
|
259
|
+
? { batteryPct: numberField(body.batteryPct) }
|
|
260
|
+
: {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stringField(value: unknown): string | undefined {
|
|
265
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function numberField(value: unknown): number | undefined {
|
|
269
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
270
|
+
? value
|
|
271
|
+
: undefined;
|
|
272
|
+
}
|