amkernel 0.1.0__py3-none-any.whl
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.
- amkernel/__init__.py +7 -0
- amkernel/__main__.py +5 -0
- amkernel/artifact_store.py +1363 -0
- amkernel/cli.py +172 -0
- amkernel/distribution.py +597 -0
- amkernel/output.py +444 -0
- amkernel/protocol.py +1464 -0
- amkernel/py.typed +0 -0
- amkernel/runner.py +1222 -0
- amkernel/runtime.py +581 -0
- amkernel/server.py +774 -0
- amkernel/skill/SKILL.md +124 -0
- amkernel/skill/references/amkernel-appium.md +46 -0
- amkernel/skill/references/official-skills.json +232 -0
- amkernel/skill/references/official-sources.json +21 -0
- amkernel/skill/scripts/verify_official_skills.py +252 -0
- amkernel/structured_output.py +328 -0
- amkernel/supervisor.py +1453 -0
- amkernel/workspace.py +305 -0
- amkernel-0.1.0.dist-info/METADATA +105 -0
- amkernel-0.1.0.dist-info/RECORD +23 -0
- amkernel-0.1.0.dist-info/WHEEL +4 -0
- amkernel-0.1.0.dist-info/entry_points.txt +3 -0
amkernel/output.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Execution-scoped stream spooling, bounds, previews, and publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import mmap
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import IO, Literal
|
|
12
|
+
|
|
13
|
+
from amkernel.artifact_store import (
|
|
14
|
+
ArtifactBundle,
|
|
15
|
+
ArtifactRecord,
|
|
16
|
+
ArtifactStoreError,
|
|
17
|
+
ArtifactWriter,
|
|
18
|
+
)
|
|
19
|
+
from amkernel.protocol import (
|
|
20
|
+
MAX_LIVE_MESSAGE_BYTES,
|
|
21
|
+
MAX_STREAM_BYTES,
|
|
22
|
+
SAFE_MIME_TYPES,
|
|
23
|
+
STREAM_TAIL_BYTES,
|
|
24
|
+
ArtifactBeginResponse,
|
|
25
|
+
ArtifactChunkResponse,
|
|
26
|
+
ArtifactEndResponse,
|
|
27
|
+
DisplayJsonResponse,
|
|
28
|
+
JsonValue,
|
|
29
|
+
Response,
|
|
30
|
+
)
|
|
31
|
+
from amkernel.structured_output import (
|
|
32
|
+
ImageInfo,
|
|
33
|
+
StructuredOutputError,
|
|
34
|
+
validate_safe_artifact,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
StreamChannel = Literal["stdout", "stderr"]
|
|
38
|
+
OutputEventKind = Literal["stdout", "stderr", "display", "image", "artifact"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class OutputSnapshot:
|
|
43
|
+
"""One replaceable advisory live-text snapshot."""
|
|
44
|
+
|
|
45
|
+
revision: int
|
|
46
|
+
channel: OutputEventKind
|
|
47
|
+
text: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OutputObserver:
|
|
51
|
+
"""Expose only the latest stream snapshot without producer backpressure."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._queue: asyncio.Queue[OutputSnapshot] = asyncio.Queue(maxsize=1)
|
|
55
|
+
|
|
56
|
+
async def next(self) -> OutputSnapshot:
|
|
57
|
+
return await self._queue.get()
|
|
58
|
+
|
|
59
|
+
def drain_latest(self, current: OutputSnapshot) -> OutputSnapshot:
|
|
60
|
+
"""Replace one held observation with the newest pending observation."""
|
|
61
|
+
while True:
|
|
62
|
+
try:
|
|
63
|
+
current = self._queue.get_nowait()
|
|
64
|
+
except asyncio.QueueEmpty:
|
|
65
|
+
return current
|
|
66
|
+
|
|
67
|
+
def _publish(self, snapshot: OutputSnapshot) -> None:
|
|
68
|
+
if self._queue.full():
|
|
69
|
+
self._queue.get_nowait()
|
|
70
|
+
self._queue.put_nowait(snapshot)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class _ByteSummary:
|
|
75
|
+
total_bytes: int = 0
|
|
76
|
+
newline_bytes: int = 0
|
|
77
|
+
last_byte: int | None = None
|
|
78
|
+
|
|
79
|
+
def feed(self, chunk: bytes | memoryview) -> None:
|
|
80
|
+
if not chunk:
|
|
81
|
+
return
|
|
82
|
+
self.total_bytes += len(chunk)
|
|
83
|
+
self.newline_bytes += (
|
|
84
|
+
chunk.count(b"\n") if isinstance(chunk, bytes) else bytes(chunk).count(b"\n")
|
|
85
|
+
)
|
|
86
|
+
self.last_byte = chunk[-1]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_lines(self) -> int:
|
|
90
|
+
return self.newline_bytes + int(self.total_bytes > 0 and self.last_byte != 0x0A)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True)
|
|
94
|
+
class _StreamState:
|
|
95
|
+
writer: ArtifactWriter
|
|
96
|
+
observed: _ByteSummary = field(default_factory=_ByteSummary)
|
|
97
|
+
discarded: _ByteSummary = field(default_factory=_ByteSummary)
|
|
98
|
+
tail: bytearray = field(default_factory=bytearray)
|
|
99
|
+
live_tail: bytearray = field(default_factory=bytearray)
|
|
100
|
+
accepted_bytes: int = 0
|
|
101
|
+
|
|
102
|
+
def accept(self, chunk: bytes | memoryview) -> None:
|
|
103
|
+
if not chunk:
|
|
104
|
+
return
|
|
105
|
+
self.writer.write(chunk)
|
|
106
|
+
self.accepted_bytes += len(chunk)
|
|
107
|
+
self._append_bounded(self.tail, chunk, STREAM_TAIL_BYTES)
|
|
108
|
+
self._append_bounded(self.live_tail, chunk, MAX_LIVE_MESSAGE_BYTES)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _append_bounded(target: bytearray, chunk: bytes | memoryview, maximum: int) -> None:
|
|
112
|
+
target.extend(chunk)
|
|
113
|
+
overflow = len(target) - maximum
|
|
114
|
+
if overflow > 0:
|
|
115
|
+
del target[:overflow]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def truncated(self) -> bool:
|
|
119
|
+
return self.accepted_bytes > len(self.tail)
|
|
120
|
+
|
|
121
|
+
def preview(self) -> str:
|
|
122
|
+
value = bytes(self.tail)
|
|
123
|
+
if self.truncated:
|
|
124
|
+
value = _trim_utf8_prefix(value)
|
|
125
|
+
return value.decode("utf-8", errors="replace")
|
|
126
|
+
|
|
127
|
+
def live_text(self) -> str:
|
|
128
|
+
text = _trim_utf8_prefix(bytes(self.live_tail)).decode("utf-8", errors="replace")
|
|
129
|
+
encoded = text.encode("utf-8")
|
|
130
|
+
if len(encoded) <= MAX_LIVE_MESSAGE_BYTES:
|
|
131
|
+
return text
|
|
132
|
+
return _trim_utf8_prefix(encoded[-MAX_LIVE_MESSAGE_BYTES:]).decode("utf-8")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class DisplayRecord:
|
|
137
|
+
sequence: int
|
|
138
|
+
kind: Literal["json", "image", "artifact"]
|
|
139
|
+
value: JsonValue | None = None
|
|
140
|
+
artifact: ArtifactRecord | None = None
|
|
141
|
+
image: ImageInfo | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(slots=True)
|
|
145
|
+
class _StructuredTransaction:
|
|
146
|
+
begin: ArtifactBeginResponse
|
|
147
|
+
writer: ArtifactWriter
|
|
148
|
+
image: IO[bytes] | None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class OutputResult:
|
|
153
|
+
stdout: str
|
|
154
|
+
stderr: str
|
|
155
|
+
total_bytes: int
|
|
156
|
+
total_lines: int
|
|
157
|
+
discarded_bytes: int
|
|
158
|
+
discarded_lines: int
|
|
159
|
+
truncated: bool
|
|
160
|
+
limit_bytes: int
|
|
161
|
+
limit_exceeded: bool
|
|
162
|
+
stdout_artifact: ArtifactRecord | None = None
|
|
163
|
+
stderr_artifact: ArtifactRecord | None = None
|
|
164
|
+
displays: tuple[DisplayRecord, ...] = ()
|
|
165
|
+
explicit_artifacts: tuple[ArtifactRecord, ...] = ()
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def empty(cls) -> OutputResult:
|
|
169
|
+
return cls("", "", 0, 0, 0, 0, False, MAX_STREAM_BYTES, False)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def artifacts(self) -> tuple[ArtifactRecord, ...]:
|
|
173
|
+
"""Return helper artifacts in sequence order, followed by retained streams."""
|
|
174
|
+
streams = tuple(
|
|
175
|
+
artifact
|
|
176
|
+
for artifact in (self.stdout_artifact, self.stderr_artifact)
|
|
177
|
+
if artifact is not None
|
|
178
|
+
)
|
|
179
|
+
return self.explicit_artifacts + streams
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class OutputPipeline:
|
|
183
|
+
"""Combine both observed streams into one bounded immutable execution result."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, bundle: ArtifactBundle) -> None:
|
|
186
|
+
self._bundle = bundle
|
|
187
|
+
self._streams = {
|
|
188
|
+
"stdout": _StreamState(bundle.open_artifact("stdout", "application/octet-stream")),
|
|
189
|
+
"stderr": _StreamState(bundle.open_artifact("stderr", "application/octet-stream")),
|
|
190
|
+
}
|
|
191
|
+
self._accepted_bytes = 0
|
|
192
|
+
self._limit_exceeded = False
|
|
193
|
+
self._failure: ArtifactStoreError | None = None
|
|
194
|
+
self._settled = False
|
|
195
|
+
self._revision = 0
|
|
196
|
+
self._observer: OutputObserver | None = None
|
|
197
|
+
self.limit_event = asyncio.Event()
|
|
198
|
+
self.failure_event = asyncio.Event()
|
|
199
|
+
self._displays: list[DisplayRecord] = []
|
|
200
|
+
self._explicit_records: list[tuple[int, ArtifactRecord]] = []
|
|
201
|
+
self._transaction: _StructuredTransaction | None = None
|
|
202
|
+
self.limit_time: float | None = None
|
|
203
|
+
self.failure_time: float | None = None
|
|
204
|
+
|
|
205
|
+
def attach_observer(self, observer: OutputObserver | None = None) -> OutputObserver:
|
|
206
|
+
if self._observer is not None:
|
|
207
|
+
raise RuntimeError("output observer is already attached")
|
|
208
|
+
attached = observer if observer is not None else OutputObserver()
|
|
209
|
+
self._observer = attached
|
|
210
|
+
return attached
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def failure(self) -> ArtifactStoreError | None:
|
|
214
|
+
return self._failure
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def limit_exceeded(self) -> bool:
|
|
218
|
+
return self._limit_exceeded
|
|
219
|
+
|
|
220
|
+
def feed(self, channel: StreamChannel, chunk: bytes) -> None:
|
|
221
|
+
if not chunk:
|
|
222
|
+
return
|
|
223
|
+
stream = self._streams[channel]
|
|
224
|
+
stream.observed.feed(chunk)
|
|
225
|
+
if self._failure is not None:
|
|
226
|
+
return
|
|
227
|
+
if self._limit_exceeded:
|
|
228
|
+
stream.discarded.feed(chunk)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
remaining = MAX_STREAM_BYTES - self._accepted_bytes
|
|
232
|
+
accepted = memoryview(chunk)[:remaining]
|
|
233
|
+
discarded = memoryview(chunk)[remaining:]
|
|
234
|
+
try:
|
|
235
|
+
stream.accept(accepted)
|
|
236
|
+
except ArtifactStoreError as error:
|
|
237
|
+
self._failure = error
|
|
238
|
+
self.failure_time = time.monotonic()
|
|
239
|
+
self.failure_event.set()
|
|
240
|
+
return
|
|
241
|
+
self._accepted_bytes += len(accepted)
|
|
242
|
+
if accepted:
|
|
243
|
+
self._publish(channel, stream.live_text())
|
|
244
|
+
if discarded:
|
|
245
|
+
self._limit_exceeded = True
|
|
246
|
+
self.limit_time = time.monotonic()
|
|
247
|
+
stream.discarded.feed(discarded)
|
|
248
|
+
self.limit_event.set()
|
|
249
|
+
|
|
250
|
+
def accept_response(self, response: Response) -> None:
|
|
251
|
+
"""Persist one already protocol-validated structured output event."""
|
|
252
|
+
try:
|
|
253
|
+
if isinstance(response, DisplayJsonResponse):
|
|
254
|
+
self._displays.append(DisplayRecord(response.sequence, "json", response.value))
|
|
255
|
+
size = len(
|
|
256
|
+
json.dumps(
|
|
257
|
+
response.value,
|
|
258
|
+
ensure_ascii=False,
|
|
259
|
+
allow_nan=False,
|
|
260
|
+
separators=(",", ":"),
|
|
261
|
+
).encode("utf-8")
|
|
262
|
+
)
|
|
263
|
+
self._publish("display", f"{size} bytes")
|
|
264
|
+
elif isinstance(response, ArtifactBeginResponse):
|
|
265
|
+
validation = (
|
|
266
|
+
tempfile.SpooledTemporaryFile(max_size=8_388_608)
|
|
267
|
+
if response.kind == "image" or response.declared_mime_type in SAFE_MIME_TYPES
|
|
268
|
+
else None
|
|
269
|
+
)
|
|
270
|
+
writer = self._bundle.open_artifact(
|
|
271
|
+
response.name,
|
|
272
|
+
response.declared_mime_type,
|
|
273
|
+
safe_mime_type=(
|
|
274
|
+
response.declared_mime_type
|
|
275
|
+
if response.kind == "image"
|
|
276
|
+
else "application/octet-stream"
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
self._transaction = _StructuredTransaction(response, writer, validation)
|
|
280
|
+
elif isinstance(response, ArtifactChunkResponse):
|
|
281
|
+
transaction = self._require_transaction(response.transaction_id)
|
|
282
|
+
transaction.writer.write(response.data)
|
|
283
|
+
if transaction.image is not None:
|
|
284
|
+
transaction.image.write(response.data)
|
|
285
|
+
elif isinstance(response, ArtifactEndResponse):
|
|
286
|
+
transaction = self._require_transaction(response.transaction_id)
|
|
287
|
+
image_info = None
|
|
288
|
+
if transaction.image is not None:
|
|
289
|
+
transaction.image.flush()
|
|
290
|
+
try:
|
|
291
|
+
if transaction.image.tell() == 0:
|
|
292
|
+
validated = validate_safe_artifact(
|
|
293
|
+
b"", transaction.begin.declared_mime_type
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
with mmap.mmap(
|
|
297
|
+
transaction.image.fileno(), 0, access=mmap.ACCESS_READ
|
|
298
|
+
) as mapped:
|
|
299
|
+
validated = validate_safe_artifact(
|
|
300
|
+
mapped, transaction.begin.declared_mime_type
|
|
301
|
+
)
|
|
302
|
+
except StructuredOutputError:
|
|
303
|
+
if transaction.begin.kind == "image":
|
|
304
|
+
raise
|
|
305
|
+
else:
|
|
306
|
+
if transaction.begin.kind == "image":
|
|
307
|
+
if validated is None:
|
|
308
|
+
raise StructuredOutputError("image validation returned no metadata")
|
|
309
|
+
image_info = validated
|
|
310
|
+
else:
|
|
311
|
+
transaction.writer._set_safe_mime_type(
|
|
312
|
+
transaction.begin.declared_mime_type
|
|
313
|
+
)
|
|
314
|
+
finally:
|
|
315
|
+
transaction.image.close()
|
|
316
|
+
record = transaction.writer.finish()
|
|
317
|
+
self._displays.append(
|
|
318
|
+
DisplayRecord(
|
|
319
|
+
transaction.begin.sequence,
|
|
320
|
+
transaction.begin.kind,
|
|
321
|
+
artifact=record,
|
|
322
|
+
image=image_info,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
self._explicit_records.append((transaction.begin.sequence, record))
|
|
326
|
+
self._transaction = None
|
|
327
|
+
self._publish(transaction.begin.kind, f"{record.size} bytes")
|
|
328
|
+
except (ArtifactStoreError, OSError, StructuredOutputError) as error:
|
|
329
|
+
if self._failure is None:
|
|
330
|
+
self._failure = (
|
|
331
|
+
error
|
|
332
|
+
if isinstance(error, ArtifactStoreError)
|
|
333
|
+
else ArtifactStoreError("structured output validation failed")
|
|
334
|
+
)
|
|
335
|
+
self.failure_time = time.monotonic()
|
|
336
|
+
self.failure_event.set()
|
|
337
|
+
|
|
338
|
+
def _publish(self, channel: OutputEventKind, text: str) -> None:
|
|
339
|
+
self._revision += 1
|
|
340
|
+
observer = self._observer
|
|
341
|
+
if observer is not None:
|
|
342
|
+
observer._publish(OutputSnapshot(self._revision, channel, text))
|
|
343
|
+
|
|
344
|
+
def _require_transaction(self, transaction_id: str) -> _StructuredTransaction:
|
|
345
|
+
transaction = self._transaction
|
|
346
|
+
if transaction is None or transaction.begin.transaction_id != transaction_id:
|
|
347
|
+
raise ArtifactStoreError("structured output transaction mismatch")
|
|
348
|
+
return transaction
|
|
349
|
+
|
|
350
|
+
def snapshot_result(self) -> OutputResult:
|
|
351
|
+
"""Return current path-free accounting without unpublished references."""
|
|
352
|
+
return self._result({}, include_artifacts=False)
|
|
353
|
+
|
|
354
|
+
async def finish(self) -> OutputResult:
|
|
355
|
+
if self._settled:
|
|
356
|
+
raise ArtifactStoreError("output pipeline is settled")
|
|
357
|
+
if self._failure is not None:
|
|
358
|
+
await self.abort()
|
|
359
|
+
raise self._failure
|
|
360
|
+
|
|
361
|
+
records: dict[str, ArtifactRecord] = {}
|
|
362
|
+
try:
|
|
363
|
+
if self._transaction is not None:
|
|
364
|
+
if self._transaction.image is not None:
|
|
365
|
+
self._transaction.image.close()
|
|
366
|
+
self._transaction.writer.discard()
|
|
367
|
+
self._transaction = None
|
|
368
|
+
retained = bool(self._explicit_records)
|
|
369
|
+
for channel in ("stdout", "stderr"):
|
|
370
|
+
stream = self._streams[channel]
|
|
371
|
+
if stream.truncated:
|
|
372
|
+
records[channel] = stream.writer.finish()
|
|
373
|
+
retained = True
|
|
374
|
+
else:
|
|
375
|
+
stream.writer.discard()
|
|
376
|
+
if retained:
|
|
377
|
+
await self._bundle.publish()
|
|
378
|
+
else:
|
|
379
|
+
await self._bundle.abort()
|
|
380
|
+
except (ArtifactStoreError, OSError) as error:
|
|
381
|
+
await self._abort_after_failure()
|
|
382
|
+
if isinstance(error, OSError):
|
|
383
|
+
raise ArtifactStoreError("output cleanup failed") from error
|
|
384
|
+
raise
|
|
385
|
+
self._settled = True
|
|
386
|
+
return self._result(records)
|
|
387
|
+
|
|
388
|
+
async def abort(self) -> None:
|
|
389
|
+
if self._settled:
|
|
390
|
+
return
|
|
391
|
+
if self._transaction is not None and self._transaction.image is not None:
|
|
392
|
+
self._transaction.image.close()
|
|
393
|
+
await self._bundle.abort()
|
|
394
|
+
self._transaction = None
|
|
395
|
+
self._settled = True
|
|
396
|
+
|
|
397
|
+
async def _abort_after_failure(self) -> None:
|
|
398
|
+
try:
|
|
399
|
+
await self._bundle.abort()
|
|
400
|
+
except (ArtifactStoreError, OSError):
|
|
401
|
+
pass
|
|
402
|
+
self._settled = True
|
|
403
|
+
|
|
404
|
+
def _result(
|
|
405
|
+
self, records: dict[str, ArtifactRecord], *, include_artifacts: bool = True
|
|
406
|
+
) -> OutputResult:
|
|
407
|
+
stdout = self._streams["stdout"]
|
|
408
|
+
stderr = self._streams["stderr"]
|
|
409
|
+
return OutputResult(
|
|
410
|
+
stdout=stdout.preview(),
|
|
411
|
+
stderr=stderr.preview(),
|
|
412
|
+
total_bytes=stdout.observed.total_bytes + stderr.observed.total_bytes,
|
|
413
|
+
total_lines=stdout.observed.total_lines + stderr.observed.total_lines,
|
|
414
|
+
discarded_bytes=stdout.discarded.total_bytes + stderr.discarded.total_bytes,
|
|
415
|
+
discarded_lines=stdout.discarded.total_lines + stderr.discarded.total_lines,
|
|
416
|
+
truncated=stdout.truncated or stderr.truncated,
|
|
417
|
+
limit_bytes=MAX_STREAM_BYTES,
|
|
418
|
+
limit_exceeded=self._limit_exceeded,
|
|
419
|
+
stdout_artifact=records.get("stdout"),
|
|
420
|
+
stderr_artifact=records.get("stderr"),
|
|
421
|
+
displays=tuple(
|
|
422
|
+
sorted(
|
|
423
|
+
(
|
|
424
|
+
display
|
|
425
|
+
for display in self._displays
|
|
426
|
+
if include_artifacts or display.kind == "json"
|
|
427
|
+
),
|
|
428
|
+
key=lambda item: item.sequence,
|
|
429
|
+
)
|
|
430
|
+
),
|
|
431
|
+
explicit_artifacts=(
|
|
432
|
+
tuple(
|
|
433
|
+
record for _, record in sorted(self._explicit_records, key=lambda item: item[0])
|
|
434
|
+
)
|
|
435
|
+
if include_artifacts
|
|
436
|
+
else ()
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _trim_utf8_prefix(value: bytes) -> bytes:
|
|
442
|
+
while value and value[0] & 0xC0 == 0x80:
|
|
443
|
+
value = value[1:]
|
|
444
|
+
return value
|